Compare commits

..

13 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
03fd74b955 docs: regenerate swagger example files with fixed array definitions
The swagger generation code already contains the fix for array definitions
with useDefinitions=true (from PR #5216). The fix ensures that when arrays
contain structs, the $ref is placed inside items rather than at the schema level.

However, the example swagger files were not regenerated after the fix,
so they still showed the old incorrect structure. This commit regenerates
the example files to reflect the corrected behavior.

Co-authored-by: kevwan <1918356+kevwan@users.noreply.github.com>
2025-10-07 02:36:07 +00:00
copilot-swe-agent[bot]
f8716fe6fa Initial plan 2025-10-07 02:28:40 +00:00
Kevin Wan
cf21cb2b0b chore: refactor to remove duplicated code (#5216) 2025-10-06 22:24:44 +08:00
Copilot
61e8894c31 Fix swagger generation: info block and server tags not included (#5215)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: kevwan <1918356+kevwan@users.noreply.github.com>
2025-10-06 22:02:42 +08:00
Copilot
7a6c3c8129 Fix swagger path generation: remove trailing slash for root routes with prefix (#5212) 2025-10-05 12:12:03 +08:00
Rizky Ikwan
875fec3e1a chore: fix typos (#5210) 2025-10-04 02:56:07 +00:00
Kevin Wan
60128c2100 chore: update goctl version (#5205) 2025-10-02 22:48:57 +08:00
Copilot
ce6d0e3ea7 fix(goctl/swagger): correct $ref placement in array definitions when useDefinitions is enabled (#5199)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: kevwan <1918356+kevwan@users.noreply.github.com>
2025-10-02 22:11:18 +08:00
Kevin Wan
fa85c84af3 chore: code refactoring (#5204) 2025-10-02 21:48:03 +08:00
Remember
440884105e feat(handler): add sseSlowThreshold (#5196) 2025-10-02 13:34:44 +00:00
Copilot
271f10598f Add complete test scaffolding support with --test flag for API projects (#5176)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: kevwan <1918356+kevwan@users.noreply.github.com>
2025-09-27 21:13:13 +08:00
Remember
cf55a88ce3 fix(rest): change SSE SetWriteDeadline error log to debug level (#5162) 2025-09-27 12:48:35 +00:00
dependabot[bot]
c1c786b14a chore(deps): bump github.com/redis/go-redis/v9 from 9.14.0 to 9.15.0 (#5193)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-27 11:51:15 +08:00
28 changed files with 822 additions and 5883 deletions

View File

@@ -100,34 +100,6 @@ func (p *Pool) Put(x any) {
p.cond.Signal()
}
// DestroyAll destroys all resources in the pool.
// It calls the destroy function on each resource and resets the pool state.
// This is useful when you need to forcefully clean up all resources, for example:
// - When removing an obsolete pool
// - When refreshing all resources after configuration changes
// - When avoiding resource leaks in dynamic pool scenarios
func (p *Pool) DestroyAll() {
p.lock.Lock()
defer p.lock.Unlock()
// Iterate through the linked list and destroy all resources
current := p.head
for current != nil {
next := current.next
if p.destroy != nil {
p.destroy(current.item)
}
current = next
}
// Reset pool state
p.head = nil
p.created = 0
// Wake up all waiting goroutines since the pool is now empty
p.cond.Broadcast()
}
// WithMaxAge returns a function to customize a Pool with given max age.
func WithMaxAge(duration time.Duration) PoolOption {
return func(pool *Pool) {

View File

@@ -107,155 +107,6 @@ func TestNewPoolPanics(t *testing.T) {
})
}
func TestPoolDestroyAll(t *testing.T) {
var destroyed []int
var destroyCount int32
destroyFunc := func(item any) {
destroyed = append(destroyed, item.(int))
atomic.AddInt32(&destroyCount, 1)
}
pool := NewPool(limit, create, destroyFunc)
// Put some resources into the pool
pool.Put(10)
pool.Put(20)
pool.Put(30)
// Destroy all resources
pool.DestroyAll()
// Verify all resources were destroyed
assert.Equal(t, int32(3), atomic.LoadInt32(&destroyCount))
assert.Contains(t, destroyed, 10)
assert.Contains(t, destroyed, 20)
assert.Contains(t, destroyed, 30)
// Verify pool is empty - next Get should create new resource
val := pool.Get()
assert.Equal(t, 1, val) // create() returns 1
}
func TestPoolDestroyAllEmpty(t *testing.T) {
var destroyCount int32
destroyFunc := func(_ any) {
atomic.AddInt32(&destroyCount, 1)
}
pool := NewPool(limit, create, destroyFunc)
// DestroyAll on empty pool should not panic
pool.DestroyAll()
// No resources should have been destroyed
assert.Equal(t, int32(0), atomic.LoadInt32(&destroyCount))
// Pool should still work normally
val := pool.Get()
assert.Equal(t, 1, val)
}
func TestPoolDestroyAllWithNilDestroy(t *testing.T) {
pool := NewPool(limit, create, nil)
// Put some resources into the pool
pool.Put(10)
pool.Put(20)
// DestroyAll with nil destroy function should not panic
pool.DestroyAll()
// Pool should be empty and work normally
val := pool.Get()
assert.Equal(t, 1, val)
}
func TestPoolDestroyAllConcurrency(t *testing.T) {
var destroyCount int32
var createCount int32
createFunc := func() any {
return atomic.AddInt32(&createCount, 1)
}
destroyFunc := func(_ any) {
atomic.AddInt32(&destroyCount, 1)
}
pool := NewPool(limit, createFunc, destroyFunc)
// Add some initial resources
for i := 0; i < 5; i++ {
pool.Put(i + 100)
}
var wg sync.WaitGroup
const goroutines = 10
// Concurrently perform various operations
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
switch id % 4 {
case 0:
// DestroyAll
pool.DestroyAll()
case 1:
// Get resources
val := pool.Get()
pool.Put(val)
case 2:
// Put resources
pool.Put(id + 1000)
case 3:
// Get and don't put back
pool.Get()
}
}(i)
}
wg.Wait()
// Final DestroyAll to clean up
pool.DestroyAll()
// Pool should work after concurrent operations
val := pool.Get()
assert.NotNil(t, val)
}
func TestPoolDestroyAllWakesWaitingGoroutines(t *testing.T) {
pool := NewPool(1, create, destroy) // Small pool size
// Fill the pool
resource := pool.Get()
assert.Equal(t, 1, resource)
var wg sync.WaitGroup
var gotResource bool
// Start a goroutine that will wait for a resource
wg.Add(1)
go func() {
defer wg.Done()
val := pool.Get() // This will block since pool is full
gotResource = true
assert.Equal(t, 1, val) // Should get a newly created resource after DestroyAll
}()
// Give the goroutine time to start waiting
time.Sleep(10 * time.Millisecond)
// DestroyAll should wake up the waiting goroutine
pool.DestroyAll()
wg.Wait()
assert.True(t, gotResource)
}
func create() any {
return 1
}

2
go.mod
View File

@@ -16,7 +16,7 @@ require (
github.com/jhump/protoreflect v1.17.0
github.com/pelletier/go-toml/v2 v2.2.2
github.com/prometheus/client_golang v1.21.1
github.com/redis/go-redis/v9 v9.14.0
github.com/redis/go-redis/v9 v9.15.0
github.com/spaolacci/murmur3 v1.1.0
github.com/stretchr/testify v1.11.1
go.etcd.io/etcd/api/v3 v3.5.15

4
go.sum
View File

@@ -154,8 +154,8 @@ github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE=
github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/redis/go-redis/v9 v9.15.0 h1:2jdes0xJxer4h3NUZrZ4OGSntGlXp4WbXju2nOTRXto=
github.com/redis/go-redis/v9 v9.15.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=

View File

@@ -389,7 +389,9 @@ func buildSSERoutes(routes []Route) []Route {
// because SSE requires the connection to be kept alive indefinitely.
rc := http.NewResponseController(w)
if err := rc.SetWriteDeadline(time.Time{}); err != nil {
logc.Errorf(r.Context(), "set conn write deadline failed: %v", err)
// Some ResponseWriter implementations (like timeoutWriter) don't support SetWriteDeadline.
// This is expected behavior and doesn't affect SSE functionality.
logc.Debugf(r.Context(), "unable to clear write deadline for SSE connection: %v", err)
}
w.Header().Set(header.ContentType, header.ContentTypeEventStream)

View File

@@ -24,12 +24,16 @@ import (
)
const (
limitBodyBytes = 1024
limitDetailedBodyBytes = 4096
defaultSlowThreshold = time.Millisecond * 500
limitBodyBytes = 1024
limitDetailedBodyBytes = 4096
defaultSlowThreshold = time.Millisecond * 500
defaultSSESlowThreshold = time.Minute * 3
)
var slowThreshold = syncx.ForAtomicDuration(defaultSlowThreshold)
var (
slowThreshold = syncx.ForAtomicDuration(defaultSlowThreshold)
sseSlowThreshold = syncx.ForAtomicDuration(defaultSSESlowThreshold)
)
// LogHandler returns a middleware that logs http request and response.
func LogHandler(next http.Handler) http.Handler {
@@ -109,6 +113,11 @@ func SetSlowThreshold(threshold time.Duration) {
slowThreshold.Set(threshold)
}
// SetSSESlowThreshold sets the slow threshold for SSE requests.
func SetSSESlowThreshold(threshold time.Duration) {
sseSlowThreshold.Set(threshold)
}
func dumpRequest(r *http.Request) string {
reqContent, err := httputil.DumpRequest(r, true)
if err != nil {
@@ -118,6 +127,14 @@ func dumpRequest(r *http.Request) string {
return string(reqContent)
}
func getSlowThreshold(r *http.Request) time.Duration {
if r.Header.Get(headerAccept) == valueSSE {
return sseSlowThreshold.Load()
} else {
return slowThreshold.Load()
}
}
func isOkResponse(code int) bool {
// not server error
return code < http.StatusInternalServerError
@@ -129,7 +146,8 @@ func logBrief(r *http.Request, code int, timer *utils.ElapsedTimer, logs *intern
logger := logx.WithContext(r.Context()).WithDuration(duration)
buf.WriteString(fmt.Sprintf("[HTTP] %s - %s %s - %s - %s",
wrapStatusCode(code), wrapMethod(r.Method), r.RequestURI, httpx.GetRemoteAddr(r), r.UserAgent()))
if duration > slowThreshold.Load() {
if duration > getSlowThreshold(r) {
logger.Slowf("[HTTP] %s - %s %s - %s - %s - slowcall(%s)",
wrapStatusCode(code), wrapMethod(r.Method), r.RequestURI, httpx.GetRemoteAddr(r), r.UserAgent(),
timex.ReprOfDuration(duration))
@@ -160,7 +178,8 @@ func logDetails(r *http.Request, response *detailLoggedResponseWriter, timer *ut
logger := logx.WithContext(r.Context())
buf.WriteString(fmt.Sprintf("[HTTP] %s - %d - %s - %s\n=> %s\n",
r.Method, code, r.RemoteAddr, timex.ReprOfDuration(duration), dumpRequest(r)))
if duration > slowThreshold.Load() {
if duration > getSlowThreshold(r) {
logger.Slowf("[HTTP] %s - %d - %s - slowcall(%s)\n=> %s\n", r.Method, code, r.RemoteAddr,
timex.ReprOfDuration(duration), dumpRequest(r))
}

View File

@@ -88,6 +88,96 @@ func TestLogHandlerSlow(t *testing.T) {
}
}
func TestLogHandlerSSE(t *testing.T) {
handlers := []func(handler http.Handler) http.Handler{
LogHandler,
DetailedLogHandler,
}
for _, logHandler := range handlers {
t.Run("SSE request with normal duration", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://localhost", http.NoBody)
req.Header.Set(headerAccept, valueSSE)
handler := logHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(defaultSlowThreshold + time.Second)
w.WriteHeader(http.StatusOK)
}))
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, req)
assert.Equal(t, http.StatusOK, resp.Code)
})
t.Run("SSE request exceeding SSE threshold", func(t *testing.T) {
originalThreshold := sseSlowThreshold.Load()
SetSSESlowThreshold(time.Millisecond * 100)
defer SetSSESlowThreshold(originalThreshold)
req := httptest.NewRequest(http.MethodGet, "http://localhost", http.NoBody)
req.Header.Set(headerAccept, valueSSE)
handler := logHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(time.Millisecond * 150)
w.WriteHeader(http.StatusOK)
}))
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, req)
assert.Equal(t, http.StatusOK, resp.Code)
})
}
}
func TestLogHandlerThresholdSelection(t *testing.T) {
tests := []struct {
name string
acceptHeader string
expectedIsSSE bool
}{
{
name: "Regular HTTP request",
acceptHeader: "text/html",
expectedIsSSE: false,
},
{
name: "SSE request",
acceptHeader: valueSSE,
expectedIsSSE: true,
},
{
name: "No Accept header",
acceptHeader: "",
expectedIsSSE: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://localhost", http.NoBody)
if tt.acceptHeader != "" {
req.Header.Set(headerAccept, tt.acceptHeader)
}
SetSlowThreshold(time.Millisecond * 100)
SetSSESlowThreshold(time.Millisecond * 200)
defer func() {
SetSlowThreshold(defaultSlowThreshold)
SetSSESlowThreshold(defaultSSESlowThreshold)
}()
handler := LogHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(time.Millisecond * 150)
w.WriteHeader(http.StatusOK)
}))
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, req)
assert.Equal(t, http.StatusOK, resp.Code)
})
}
}
func TestDetailedLogHandler_LargeBody(t *testing.T) {
lbuf := logtest.NewCollector(t)
@@ -139,6 +229,12 @@ func TestSetSlowThreshold(t *testing.T) {
assert.Equal(t, time.Second, slowThreshold.Load())
}
func TestSetSSESlowThreshold(t *testing.T) {
assert.Equal(t, defaultSSESlowThreshold, sseSlowThreshold.Load())
SetSSESlowThreshold(time.Minute * 10)
assert.Equal(t, time.Minute*10, sseSlowThreshold.Load())
}
func TestWrapMethodWithColor(t *testing.T) {
// no tty
assert.Equal(t, http.MethodGet, wrapMethod(http.MethodGet))

View File

@@ -92,7 +92,7 @@ Port: 0
Path: "/",
Handler: nil,
}, WithJwt("thesecret"), WithSignature(SignatureConf{}),
WithJwtTransition("preivous", "thenewone"))
WithJwtTransition("previous", "thenewone"))
func() {
defer func() {

View File

@@ -118,6 +118,8 @@ func DoGenProjectWithModule(apiFile, dir, moduleName, style string, withTest boo
if withTest {
logx.Must(genHandlersTest(dir, rootPkg, projectPkg, cfg, api))
logx.Must(genLogicTest(dir, rootPkg, projectPkg, cfg, api))
logx.Must(genServiceContextTest(dir, rootPkg, projectPkg, cfg, api))
logx.Must(genIntegrationTest(dir, rootPkg, projectPkg, cfg, api))
}
if err := backupAndSweep(apiFile); err != nil {

View File

@@ -0,0 +1,42 @@
package gogen
import (
_ "embed"
"github.com/zeromicro/go-zero/tools/goctl/api/spec"
"github.com/zeromicro/go-zero/tools/goctl/config"
"github.com/zeromicro/go-zero/tools/goctl/internal/version"
"github.com/zeromicro/go-zero/tools/goctl/util/format"
)
//go:embed integration_test.tpl
var integrationTestTemplate string
func genIntegrationTest(dir, rootPkg, projectPkg string, cfg *config.Config, api *spec.ApiSpec) error {
serviceName := api.Service.Name
if len(serviceName) == 0 {
serviceName = "server"
}
filename, err := format.FileNamingFormat(cfg.NamingFormat, serviceName)
if err != nil {
return err
}
return genFile(fileGenConfig{
dir: dir,
subdir: "",
filename: filename + "_test.go",
templateName: "integrationTestTemplate",
category: category,
templateFile: integrationTestTemplateFile,
builtinTemplate: integrationTestTemplate,
data: map[string]any{
"projectPkg": projectPkg,
"serviceName": serviceName,
"version": version.BuildVersion,
"hasRoutes": len(api.Service.Routes()) > 0,
"routes": api.Service.Routes(),
},
})
}

View File

@@ -0,0 +1,34 @@
package gogen
import (
_ "embed"
"github.com/zeromicro/go-zero/tools/goctl/api/spec"
"github.com/zeromicro/go-zero/tools/goctl/config"
"github.com/zeromicro/go-zero/tools/goctl/internal/version"
"github.com/zeromicro/go-zero/tools/goctl/util/format"
)
//go:embed svc_test.tpl
var svcTestTemplate string
func genServiceContextTest(dir, rootPkg, projectPkg string, cfg *config.Config, api *spec.ApiSpec) error {
filename, err := format.FileNamingFormat(cfg.NamingFormat, contextFilename)
if err != nil {
return err
}
return genFile(fileGenConfig{
dir: dir,
subdir: contextDir,
filename: filename + "_test.go",
templateName: "svcTestTemplate",
category: category,
templateFile: svcTestTemplateFile,
builtinTemplate: svcTestTemplate,
data: map[string]any{
"projectPkg": projectPkg,
"version": version.BuildVersion,
},
})
}

View File

@@ -0,0 +1,120 @@
// Code scaffolded by goctl. Safe to edit.
// goctl {{.version}}
package main
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"{{.projectPkg}}/internal/config"
"{{.projectPkg}}/internal/handler"
"{{.projectPkg}}/internal/svc"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zeromicro/go-zero/rest"
)
func TestMain(m *testing.M) {
// TODO: Add setup/teardown logic here if needed
m.Run()
}
func TestServerIntegration(t *testing.T) {
// Create test server
c := config.Config{
RestConf: rest.RestConf{
Host: "127.0.0.1",
Port: 0, // Use random available port
},
}
server := rest.MustNewServer(c.RestConf)
defer server.Stop()
ctx := svc.NewServiceContext(c)
handler.RegisterHandlers(server, ctx)
// Start server in background
go func() {
server.Start()
}()
// Wait for server to start
time.Sleep(100 * time.Millisecond)
tests := []struct {
name string
method string
path string
body string
expectedStatus int
setup func()
}{
{
name: "health check",
method: "GET",
path: "/health",
expectedStatus: http.StatusNotFound, // Adjust based on actual routes
setup: func() {},
},
{{if .hasRoutes}}{{range .routes}}{
name: "{{.Method}} {{.Path}}",
method: "{{.Method}}",
path: "{{.Path}}",
expectedStatus: http.StatusOK, // TODO: Adjust expected status
setup: func() {
// TODO: Add setup logic for this endpoint
},
},
{{end}}{{end}}{
name: "not found route",
method: "GET",
path: "/nonexistent",
expectedStatus: http.StatusNotFound,
setup: func() {},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.setup()
req, err := http.NewRequest(tt.method, tt.path, nil)
require.NoError(t, err)
rr := httptest.NewRecorder()
server.ServeHTTP(rr, req)
assert.Equal(t, tt.expectedStatus, rr.Code)
// TODO: Add response body assertions
t.Logf("Response: %s", rr.Body.String())
})
}
}
func TestServerLifecycle(t *testing.T) {
c := config.Config{
RestConf: rest.RestConf{
Host: "127.0.0.1",
Port: 0,
},
}
server := rest.MustNewServer(c.RestConf)
// Test server can start and stop without errors
ctx := svc.NewServiceContext(c)
handler.RegisterHandlers(server, ctx)
// In a real integration test, you might start the server in a goroutine
// and test actual HTTP requests, but for scaffolding we keep it simple
server.Stop()
// TODO: Add more lifecycle tests as needed
assert.True(t, true, "Server lifecycle test passed")
}

17
tools/goctl/api/gogen/jwt.api Executable file
View File

@@ -0,0 +1,17 @@
type Request {
Name string `path:"name,options=you|me"`
}
type Response {
Message string `json:"message"`
}
@server(
jwt: Auth
jwtTransition: Trans
middleware: TokenValidate
)
service A-api {
@handler GreetHandler
get /greet/from/:name(Request) returns (Response)
}

View File

@@ -0,0 +1,60 @@
// Code scaffolded by goctl. Safe to edit.
// goctl {{.version}}
package svc
import (
"testing"
"{{.projectPkg}}/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewServiceContext(t *testing.T) {
tests := []struct {
name string
config config.Config
setup func() config.Config
}{
{
name: "default config",
setup: func() config.Config {
return config.Config{}
},
},
{
name: "valid config",
setup: func() config.Config {
return config.Config{
// TODO: Add valid config values here
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := tt.setup()
svcCtx := NewServiceContext(c)
// Basic assertions
require.NotNil(t, svcCtx)
assert.Equal(t, c, svcCtx.Config)
// TODO: Add additional assertions for middleware and dependencies
})
}
}
func TestServiceContext_Initialization(t *testing.T) {
c := config.Config{}
svcCtx := NewServiceContext(c)
// Verify service context is properly initialized
assert.NotNil(t, svcCtx)
assert.Equal(t, c, svcCtx.Config)
// TODO: Add tests for middleware initialization if any
// TODO: Add tests for external dependencies if any
}

View File

@@ -22,6 +22,8 @@ const (
routesTemplateFile = "routes.tpl"
routesAdditionTemplateFile = "route-addition.tpl"
typesTemplateFile = "types.tpl"
svcTestTemplateFile = "svc_test.tpl"
integrationTestTemplateFile = "integration_test.tpl"
)
var templates = map[string]string{
@@ -39,6 +41,8 @@ var templates = map[string]string{
routesTemplateFile: routesTemplate,
routesAdditionTemplateFile: routesAdditionTemplate,
typesTemplateFile: typesTemplate,
svcTestTemplateFile: svcTestTemplate,
integrationTestTemplateFile: integrationTestTemplate,
}
// Category returns the category of the api files.

View File

@@ -8,70 +8,66 @@ import (
)
func getBoolFromKVOrDefault(properties map[string]string, key string, def bool) bool {
if len(properties) == 0 {
return def
}
md := metadata.New(properties)
val := md.Get(key)
if len(val) == 0 {
return def
}
//I think this function and those below should handle error, but they didn't.
//Since a default value (def) is provided, any parsing errors will result in the default being returned.
str, err := strconv.Unquote(val[0])
if err != nil || len(str) == 0 {
return def
}
res, _ := strconv.ParseBool(str)
return res
}
return getOrDefault(properties, key, def, func(str string, def bool) bool {
res, err := strconv.ParseBool(str)
if err != nil {
return def
}
func getStringFromKVOrDefault(properties map[string]string, key string, def string) string {
if len(properties) == 0 {
return def
}
md := metadata.New(properties)
val := md.Get(key)
if len(val) == 0 {
return def
}
str, err := strconv.Unquote(val[0])
if err != nil || len(str) == 0 {
return def
}
return str
}
func getListFromInfoOrDefault(properties map[string]string, key string, def []string) []string {
if len(properties) == 0 {
return def
}
md := metadata.New(properties)
val := md.Get(key)
if len(val) == 0 {
return def
}
str, err := strconv.Unquote(val[0])
if err != nil || len(str) == 0 {
return def
}
resp := util.FieldsAndTrimSpace(str, commaRune)
if len(resp) == 0 {
return def
}
return resp
return res
})
}
func getFirstUsableString(def ...string) string {
if len(def) == 0 {
return ""
}
for _, val := range def {
str, err := strconv.Unquote(val)
if err == nil && len(str) != 0 {
return str
}
}
return ""
}
func getListFromInfoOrDefault(properties map[string]string, key string, def []string) []string {
return getOrDefault(properties, key, def, func(str string, def []string) []string {
resp := util.FieldsAndTrimSpace(str, commaRune)
if len(resp) == 0 {
return def
}
return resp
})
}
// getOrDefault abstracts the common logic for fetching, unquoting, and defaulting.
func getOrDefault[T any](properties map[string]string, key string, def T, convert func(string, T) T) T {
if len(properties) == 0 {
return def
}
md := metadata.New(properties)
val := md.Get(key)
if len(val) == 0 {
return def
}
str := val[0]
if unquoted, err := strconv.Unquote(str); err == nil {
str = unquoted
}
if len(str) == 0 {
return def
}
return convert(str, def)
}
func getStringFromKVOrDefault(properties map[string]string, key string, def string) string {
return getOrDefault(properties, key, def, func(str string, def string) string {
return str
})
}

View File

@@ -21,6 +21,19 @@ func Test_getBoolFromKVOrDefault(t *testing.T) {
assert.False(t, getBoolFromKVOrDefault(properties, "empty_value", false))
assert.False(t, getBoolFromKVOrDefault(nil, "nil", false))
assert.False(t, getBoolFromKVOrDefault(map[string]string{}, "empty", false))
// Test with unquoted values (as stored by RawText())
unquotedProperties := map[string]string{
"enabled": "true",
"disabled": "false",
"invalid": "notabool",
"empty_value": "",
}
assert.True(t, getBoolFromKVOrDefault(unquotedProperties, "enabled", false))
assert.False(t, getBoolFromKVOrDefault(unquotedProperties, "disabled", true))
assert.False(t, getBoolFromKVOrDefault(unquotedProperties, "invalid", false))
assert.False(t, getBoolFromKVOrDefault(unquotedProperties, "empty_value", false))
}
func Test_getStringFromKVOrDefault(t *testing.T) {
@@ -34,6 +47,17 @@ func Test_getStringFromKVOrDefault(t *testing.T) {
assert.Equal(t, "default", getStringFromKVOrDefault(properties, "missing", "default"))
assert.Equal(t, "default", getStringFromKVOrDefault(nil, "nil", "default"))
assert.Equal(t, "default", getStringFromKVOrDefault(map[string]string{}, "empty", "default"))
// Test with unquoted values (as stored by RawText())
unquotedProperties := map[string]string{
"name": "example",
"title": "Demo API",
"empty": "",
}
assert.Equal(t, "example", getStringFromKVOrDefault(unquotedProperties, "name", "default"))
assert.Equal(t, "Demo API", getStringFromKVOrDefault(unquotedProperties, "title", "default"))
assert.Equal(t, "default", getStringFromKVOrDefault(unquotedProperties, "empty", "default"))
}
func Test_getListFromInfoOrDefault(t *testing.T) {
@@ -50,4 +74,18 @@ func Test_getListFromInfoOrDefault(t *testing.T) {
assert.Equal(t, []string{"default"}, getListFromInfoOrDefault(map[string]string{
"foo": ",,",
}, "foo", []string{"default"}))
// Test with unquoted values (as stored by RawText())
unquotedProperties := map[string]string{
"list": "a, b, c",
"schemes": "http,https",
"tags": "query",
"empty": "",
}
// Note: FieldsAndTrimSpace doesn't actually trim the spaces from returned values
assert.Equal(t, []string{"a", " b", " c"}, getListFromInfoOrDefault(unquotedProperties, "list", []string{"default"}))
assert.Equal(t, []string{"http", "https"}, getListFromInfoOrDefault(unquotedProperties, "schemes", []string{"default"}))
assert.Equal(t, []string{"query"}, getListFromInfoOrDefault(unquotedProperties, "tags", []string{"default"}))
assert.Equal(t, []string{"default"}, getListFromInfoOrDefault(unquotedProperties, "empty", []string{"default"}))
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -19,7 +19,11 @@ func spec2Paths(ctx Context, srv apiSpec.Service) *spec.Paths {
for _, route := range group.Routes {
routPath := pathVariable2SwaggerVariable(ctx, route.Path)
if len(prefix) > 0 && prefix != "." {
routPath = "/" + path.Clean(prefix) + routPath
if routPath == "/" {
routPath = "/" + path.Clean(prefix)
} else {
routPath = "/" + path.Clean(prefix) + routPath
}
}
pathItem := spec2Path(ctx, group, route)
existPathItem, ok := paths.Paths[routPath]

View File

@@ -0,0 +1,90 @@
package swagger
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/zeromicro/go-zero/tools/goctl/api/spec"
)
func TestSpec2PathsWithRootRoute(t *testing.T) {
tests := []struct {
name string
prefix string
routePath string
expectedPath string
}{
{
name: "prefix with root route",
prefix: "/api/v1/shoppings",
routePath: "/",
expectedPath: "/api/v1/shoppings",
},
{
name: "prefix with sub route",
prefix: "/api/v1/shoppings",
routePath: "/list",
expectedPath: "/api/v1/shoppings/list",
},
{
name: "empty prefix with root route",
prefix: "",
routePath: "/",
expectedPath: "/",
},
{
name: "empty prefix with sub route",
prefix: "",
routePath: "/list",
expectedPath: "/list",
},
{
name: "prefix with trailing slash and root route",
prefix: "/api/v1/shoppings/",
routePath: "/",
expectedPath: "/api/v1/shoppings",
},
{
name: "prefix without leading slash and root route",
prefix: "api/v1/shoppings",
routePath: "/",
expectedPath: "/api/v1/shoppings",
},
{
name: "single level prefix with root route",
prefix: "/api",
routePath: "/",
expectedPath: "/api",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
srv := spec.Service{
Groups: []spec.Group{
{
Annotation: spec.Annotation{
Properties: map[string]string{
propertyKeyPrefix: tt.prefix,
},
},
Routes: []spec.Route{
{
Method: "get",
Path: tt.routePath,
Handler: "TestHandler",
},
},
},
},
}
ctx := testingContext(t)
paths := spec2Paths(ctx, srv)
assert.Contains(t, paths.Paths, tt.expectedPath,
"Expected path %s not found in generated paths. Got: %v",
tt.expectedPath, paths.Paths)
})
}
}

View File

@@ -70,15 +70,40 @@ func propertiesFromType(ctx Context, tp apiSpec.Type) (spec.SchemaProperties, []
switch sampleTypeFromGoType(ctx, member.Type) {
case swaggerTypeArray:
schema.Items = itemsFromGoType(ctx, member.Type)
// Special handling for arrays with useDefinitions
if ctx.UseDefinitions {
// For arrays, check if the array element (not the array itself) contains a struct
if arrayType, ok := member.Type.(apiSpec.ArrayType); ok {
if structName, containsStruct := containsStruct(arrayType.Value); containsStruct {
// Set the $ref inside the items, not at the schema level
schema.Items = &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Ref: spec.MustCreateRef(getRefName(structName)),
},
},
}
}
}
}
case swaggerTypeObject:
p, r := propertiesFromType(ctx, member.Type)
schema.Properties = p
schema.Required = r
}
if ctx.UseDefinitions {
structName, containsStruct := containsStruct(member.Type)
if containsStruct {
schema.SchemaProps.Ref = spec.MustCreateRef(getRefName(structName))
// For objects with useDefinitions, set $ref at schema level
if ctx.UseDefinitions {
structName, containsStruct := containsStruct(member.Type)
if containsStruct {
schema.SchemaProps.Ref = spec.MustCreateRef(getRefName(structName))
}
}
default:
// For non-array, non-object types, apply useDefinitions logic
if ctx.UseDefinitions {
structName, containsStruct := containsStruct(member.Type)
if containsStruct {
schema.SchemaProps.Ref = spec.MustCreateRef(getRefName(structName))
}
}
}

View File

@@ -3,6 +3,7 @@ package swagger
import (
"testing"
"github.com/zeromicro/go-zero/tools/goctl/api/spec"
"github.com/stretchr/testify/assert"
)
@@ -23,3 +24,117 @@ func Test_pathVariable2SwaggerVariable(t *testing.T) {
assert.Equal(t, tc.expected, result)
}
}
func TestArrayDefinitionsBug(t *testing.T) {
// Test case for the bug where array of structs with useDefinitions
// generates incorrect swagger JSON structure
// Context with useDefinitions enabled
ctx := Context{
UseDefinitions: true,
}
// Create a test struct containing an array of structs
testStruct := spec.DefineStruct{
RawName: "TestStruct",
Members: []spec.Member{
{
Name: "ArrayField",
Type: spec.ArrayType{
Value: spec.DefineStruct{
RawName: "ItemStruct",
Members: []spec.Member{
{
Name: "ItemName",
Type: spec.PrimitiveType{RawName: "string"},
Tag: `json:"itemName"`,
},
},
},
},
Tag: `json:"arrayField"`,
},
},
}
// Get properties from the struct
properties, _ := propertiesFromType(ctx, testStruct)
// Check that we have the array field
assert.Contains(t, properties, "arrayField")
arrayField := properties["arrayField"]
// 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")
// 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")
assert.Equal(t, "#/definitions/ItemStruct", arrayField.Items.Schema.Ref.String())
// Verify there are no other properties in the items when using $ref
assert.Nil(t, arrayField.Items.Schema.Properties, "Items with $ref should not have properties")
assert.Empty(t, arrayField.Items.Schema.Required, "Items with $ref should not have required")
assert.Empty(t, arrayField.Items.Schema.Type, "Items with $ref should not have type")
}
func TestArrayWithoutDefinitions(t *testing.T) {
// Test that arrays work correctly when useDefinitions is false
ctx := Context{
UseDefinitions: false, // This is the default
}
// Create the same test struct
testStruct := spec.DefineStruct{
RawName: "TestStruct",
Members: []spec.Member{
{
Name: "ArrayField",
Type: spec.ArrayType{
Value: spec.DefineStruct{
RawName: "ItemStruct",
Members: []spec.Member{
{
Name: "ItemName",
Type: spec.PrimitiveType{RawName: "string"},
Tag: `json:"itemName"`,
},
},
},
},
Tag: `json:"arrayField"`,
},
},
}
properties, _ := propertiesFromType(ctx, testStruct)
assert.Contains(t, properties, "arrayField")
arrayField := properties["arrayField"]
// Should be array type
assert.Equal(t, "array", arrayField.Type[0])
// Should have items with full schema, no $ref
assert.NotNil(t, arrayField.Items)
assert.NotNil(t, arrayField.Items.Schema)
// Should NOT have $ref at schema level
assert.Empty(t, arrayField.Ref.String(), "Schema should not have $ref when useDefinitions is false")
// Should NOT have $ref in items either
assert.Empty(t, arrayField.Items.Schema.Ref.String(), "Items should not have $ref when useDefinitions is false")
// Should have full schema properties in items
assert.Equal(t, "object", arrayField.Items.Schema.Type[0])
assert.Contains(t, arrayField.Items.Schema.Properties, "itemName")
assert.Equal(t, []string{"itemName"}, arrayField.Items.Schema.Required)
}

View File

@@ -16,7 +16,7 @@ require (
github.com/withfig/autocomplete-tools/integrations/cobra v1.2.1
github.com/zeromicro/antlr v0.0.1
github.com/zeromicro/ddl-parser v1.0.5
github.com/zeromicro/go-zero v1.9.0
github.com/zeromicro/go-zero v1.9.1
golang.org/x/text v0.22.0
google.golang.org/grpc v1.65.0
google.golang.org/protobuf v1.36.5
@@ -47,8 +47,8 @@ require (
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/gofuzz v1.2.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grafana/pyroscope-go v1.2.4 // indirect
github.com/grafana/pyroscope-go/godeltaprof v0.1.8 // indirect
github.com/grafana/pyroscope-go v1.2.7 // indirect
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
@@ -72,7 +72,7 @@ require (
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/redis/go-redis/v9 v9.12.1 // indirect
github.com/redis/go-redis/v9 v9.15.0 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect

View File

@@ -75,10 +75,10 @@ github.com/gookit/assert v0.1.1 h1:lh3GcawXe/p+cU7ESTZ5Ui3Sm/x8JWpIis4/1aF0mY0=
github.com/gookit/assert v0.1.1/go.mod h1:jS5bmIVQZTIwk42uXl4lyj4iaaxx32tqH16CFj0VX2E=
github.com/gookit/color v1.6.0 h1:JjJXBTk1ETNyqyilJhkTXJYYigHG24TM9Xa2M1xAhRA=
github.com/gookit/color v1.6.0/go.mod h1:9ACFc7/1IpHGBW8RwuDm/0YEnhg3dwwXpoMsmtyHfjs=
github.com/grafana/pyroscope-go v1.2.4 h1:B22GMXz+O0nWLatxLuaP7o7L9dvP0clLvIpmeEQQM0Q=
github.com/grafana/pyroscope-go v1.2.4/go.mod h1:zzT9QXQAp2Iz2ZdS216UiV8y9uXJYQiGE1q8v1FyhqU=
github.com/grafana/pyroscope-go/godeltaprof v0.1.8 h1:iwOtYXeeVSAeYefJNaxDytgjKtUuKQbJqgAIjlnicKg=
github.com/grafana/pyroscope-go/godeltaprof v0.1.8/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU=
github.com/grafana/pyroscope-go v1.2.7 h1:VWBBlqxjyR0Cwk2W6UrE8CdcdD80GOFNutj0Kb1T8ac=
github.com/grafana/pyroscope-go v1.2.7/go.mod h1:o/bpSLiJYYP6HQtvcoVKiE9s5RiNgjYTj1DhiddP2Pc=
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og=
github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
@@ -148,8 +148,8 @@ github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/redis/go-redis/v9 v9.12.1 h1:k5iquqv27aBtnTm2tIkROUDp8JBXhXZIVu1InSgvovg=
github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/redis/go-redis/v9 v9.15.0 h1:2jdes0xJxer4h3NUZrZ4OGSntGlXp4WbXju2nOTRXto=
github.com/redis/go-redis/v9 v9.15.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -185,8 +185,8 @@ github.com/zeromicro/antlr v0.0.1 h1:CQpIn/dc0pUjgGQ81y98s/NGOm2Hfru2NNio2I9mQgk
github.com/zeromicro/antlr v0.0.1/go.mod h1:nfpjEwFR6Q4xGDJMcZnCL9tEfQRgszMwu3rDz2Z+p5M=
github.com/zeromicro/ddl-parser v1.0.5 h1:LaVqHdzMTjasua1yYpIYaksxKqRzFrEukj2Wi2EbWaQ=
github.com/zeromicro/ddl-parser v1.0.5/go.mod h1:ISU/8NuPyEpl9pa17Py9TBPetMjtsiHrb9f5XGiYbo8=
github.com/zeromicro/go-zero v1.9.0 h1:hlVtQCSHPszQdcwZTawzGwTej1G2mhHybYzMRLuwCt4=
github.com/zeromicro/go-zero v1.9.0/go.mod h1:TMyCxiaOjLQ3YxyYlJrejaQZF40RlzQ3FVvFu5EbcV4=
github.com/zeromicro/go-zero v1.9.1 h1:GZCl4jun/ZgZHnSvX3SSNDHf+tEGmEQ8x2Z23xjHa9g=
github.com/zeromicro/go-zero v1.9.1/go.mod h1:bHOl7Xr7EV/iHZWEqsUNJwFc/9WgAMrPpPagYvOaMtY=
go.etcd.io/etcd/api/v3 v3.5.15 h1:3KpLJir1ZEBrYuV2v+Twaa/e2MdDCEZ/70H+lzEiwsk=
go.etcd.io/etcd/api/v3 v3.5.15/go.mod h1:N9EhGzXq58WuMllgH9ZvnEr7SI9pS0k0+DHZezGp7jM=
go.etcd.io/etcd/client/pkg/v3 v3.5.15 h1:fo0HpWz/KlHGMCC+YejpiCmyWDEuIpnTDzpJLB5fWlA=

View File

@@ -6,7 +6,7 @@ import (
)
// BuildVersion is the version of goctl.
const BuildVersion = "1.9.1-alpha"
const BuildVersion = "1.9.1"
var tag = map[string]int{"pre-alpha": 0, "alpha": 1, "pre-beta": 2, "beta": 3, "released": 4, "": 5}

View File

@@ -5,7 +5,7 @@ import (
"github.com/zeromicro/go-zero/tools/goctl/util/format"
)
// BeforeCommands run before comamnd run to show some migration notes
// BeforeCommands run before command run to show some migration notes
func BeforeCommands(dir, style string) error {
if err := migrateBefore1_3_4(dir, style); err != nil {
return err

View File

@@ -43,7 +43,7 @@ func Install(cacheDir string) (string, error) {
case vars.OsLinux:
downloadUrl = url[fmt.Sprintf("%s_%d", vars.OsLinux, bit)]
default:
return "", fmt.Errorf("unsupport OS: %q", goos)
return "", fmt.Errorf("unsupported OS: %q", goos)
}
err := downloader.Download(downloadUrl, tempFile)