Repository: zeromicro/go-zero (Go)

Issue:
goctl v1.9.1 makes many swagger properties lost
**Describe the bug**

Swagger docs generated by v1.9.1 are different from those generated by v1.9.0 even when no `.api` files get changed.

**To Reproduce**
Steps to reproduce the behavior, if applicable:

1. Install goctl v1.9.0, generate swagger docs with .api definition files
2. Git track the docs.
3. Install goctl v1.9.1, regenerate the docs

Changing go-zero versions doesn't seem to affect the outcome.

**Expected behavior**

The release notes say that v1.9.1 fixes a $ref bug, but actually it causes changes far more than that. Many swagger properties, groups infomation get lost.

**Screenshots**

<img width="2704" height="1904" alt="Image" src="https://github.com/user-attachments/assets/6cb1fe26-8afd-4678-abd4-462a2d0f1485" />

**Environments (please complete the following information):**
 - OS: macOS 15.6.1 (24G90)
 - go-zero version: v1.9.1
 - goctl version: v1.9.1



Discussion:
The info block unchanged，please confirm. However, the expected response body should be linked using the `$ref` format. This is a legacy issue from before the optimization.
> The info block unchanged，please confirm.

Yes, I confirm this.

The only things I did between the screenshot diffs were changing goctl version from v1.9.0 and v1.9.1 and re-running `goctl api swagger`.

Involved `.api` files:

```api
# base.api
syntax = "v1"

import "types_base.api"

info (
	title:          "Base API"
	description:    "For non-business APIs."
	version:        "v1"
	consumes:       "application/json"
	produces:       "application/json"
	schemes:        "http,https"
	host:           "localhost:8888"
	basePath:       "/"
	useDefinitions: true
)

type Request {}

type Response {
	BaseResp
}

@server (
	prefix: /xz-server
)
service base-api {
	@doc (
		summary: "Check server health status."
	)
	@handler HealthCheck
	get /health (Request) returns (Response)
}
```

```api
# types_base.api
syntax = "v1"

type BaseResp {
	HTTPCode   int    `json:"-"`
	CustomCode string `json:"code"`
	Msg        string `json:"msg"`
	Data       *any   `json:"data"`
}
```
v1.9.1 Issues:
  ❌ Info block missing title and description
  ❌ No host field
  ❌ No definitions section
  ❌ Response schema is inlined (no $ref)

v1.9.2 Fixed:
  ✅ Info block has title and description
  ✅ Host field present
  ✅ Definitions section exists
  ✅ Response uses $ref to definitions

=== Root Cause ===
v1.9.1 had a bug in annotation.go where strconv.Unquote() was called
on unquoted values, causing all info block properties to be ignored.

Fixed in commit 61e8894c (included in v1.9.2)

=== Conclusion ===
Solution: Upgrade to goctl v1.9.2.
> v1.9.1 Issues: ❌ Info block missing title and description ❌ No host field ❌ No definitions section ❌ Response schema is inlined (no $ref)
> 
> v1.9.2 Fixed: ✅ Info block has title and description ✅ Host field present ✅ Definitions section exists ✅ Response uses $ref to definitions
> 
> === Root Cause === v1.9.1 had a bug in annotation.go where strconv.Unquote() was called on unquoted values, causing all info block properties to be ignored.
> 
> Fixed in commit [61e8894](https://github.com/zeromicro/go-zero/commit/61e8894c3174aa095f6eaedd88da1e5c568ee72c) (included in v1.9.2)
> 
> === Conclusion === Solution: Upgrade to goctl v1.9.2.

Hi, @kevwan 

Is goctl@v1.9.2 still not released?

```sh
go install github.com/zeromicro/go-zero/tools/goctl@v1.9.2
go: downloading github.com/zeromicro/go-zero v1.9.2
go: github.com/zeromicro/go-zero/tools/goctl@v1.9.2: module github.com/zeromicro/go-zero@v1.9.2 found, but does not contain package github.com/zeromicro/go-zero/tools/goctl
```
I'm working on it.
If possible, would you please verify it with master version?
> I'm working on it. If possible, would you please verify it with master version?

@kevwan Sure. I've tested [commit 3f756a2cbf356ce126fe6e421d1787c7b399eeb5](https://github.com/zeromicro/go-zero/commit/3f756a2cbf356ce126fe6e421d1787c7b399eeb5) as below:

```sh
$ pwd
/Users/shaobiaolin/pc.macos/dev/go-zero/tools/goctl

$ make mac
GOOS=darwin go build -ldflags="-s -w" -o goctl-darwin goctl.go

$ mv goctl-darwin ~/go/bin/goctl
$ goctl --version
goctl version 1.9.2 darwin/arm64
```

Then generated docs as always. This issue no longer exists.

I also tried on other `.api` files. There were some other changes and I'm not sure if they're expected.

1. Some swagger property definitions were replaced with `$ref` (seems right https://github.com/zeromicro/go-zero/issues/5229#issuecomment-3392993539).
2. **All HTTP API level summaries were removed**.

<img width="2748" height="640" alt="Image" src="https://github.com/user-attachments/assets/abcf76c0-6ff8-423d-b686-906b331f06ea" />
Thanks for your detail information!

I'll verify the problems that you mentioned, and will release it tomorrow.
@Octobug I've submitted the fix. Would you please verify it? Thanks a lot!

BTW: this issue was closed by the related PR.
> [@Octobug](https://github.com/Octobug) I've submitted the fix. Would you please verify it? Thanks a lot!
> 
> BTW: this issue was closed by the related PR.

I've tried the [latest commit](https://github.com/zeromicro/go-zero/commit/80c320b46ed298dda5f15976cb299c7f5809afc3), it works fine.

Thanks for your hard work! ❤️ @kevwan 



Patch:
diff --git a/tools/goctl/api/swagger/annotation.go b/tools/goctl/api/swagger/annotation.go
index 7b21bb6502f3..cd6736ea9ce3 100644
--- a/tools/goctl/api/swagger/annotation.go
+++ b/tools/goctl/api/swagger/annotation.go
@@ -24,10 +24,15 @@ func getFirstUsableString(def ...string) string {
 	}
 
 	for _, val := range def {
-		str, err := strconv.Unquote(val)
-		if err == nil && len(str) != 0 {
+		// Try to unquote if it's a quoted string
+		if str, err := strconv.Unquote(val); err == nil && len(str) != 0 {
 			return str
 		}
+
+		// Otherwise, use the value as-is if it's not empty
+		if len(val) != 0 {
+			return val
+		}
 	}
 
 	return ""


Test patch:
diff --git a/tools/goctl/api/swagger/annotation_test.go b/tools/goctl/api/swagger/annotation_test.go
index f26b97f93ec2..9f94d8895996 100644
--- a/tools/goctl/api/swagger/annotation_test.go
+++ b/tools/goctl/api/swagger/annotation_test.go
@@ -89,3 +89,108 @@ func Test_getListFromInfoOrDefault(t *testing.T) {
 	assert.Equal(t, []string{"query"}, getListFromInfoOrDefault(unquotedProperties, "tags", []string{"default"}))
 	assert.Equal(t, []string{"default"}, getListFromInfoOrDefault(unquotedProperties, "empty", []string{"default"}))
 }
+
+func Test_getFirstUsableString(t *testing.T) {
+	t.Run("empty input", func(t *testing.T) {
+		result := getFirstUsableString()
+		assert.Equal(t, "", result, "should return empty string for no arguments")
+	})
+
+	t.Run("single plain string", func(t *testing.T) {
+		result := getFirstUsableString("Check server health status.")
+		assert.Equal(t, "Check server health status.", result)
+	})
+
+	t.Run("single quoted string", func(t *testing.T) {
+		// This is how Go would represent a quoted string literal
+		result := getFirstUsableString(`"Check server health status."`)
+		assert.Equal(t, "Check server health status.", result, "should unquote quoted strings")
+	})
+
+	t.Run("multiple plain strings", func(t *testing.T) {
+		result := getFirstUsableString("", "second", "third")
+		assert.Equal(t, "second", result, "should return first non-empty string")
+	})
+
+	t.Run("handler name fallback", func(t *testing.T) {
+		// Simulates the real use case: @doc text, handler name
+		result := getFirstUsableString("", "HealthCheck")
+		assert.Equal(t, "HealthCheck", result, "should fallback to handler name")
+	})
+
+	t.Run("doc text over handler name", func(t *testing.T) {
+		// Simulates the real use case with @doc text
+		result := getFirstUsableString("Check server health status.", "HealthCheck")
+		assert.Equal(t, "Check server health status.", result, "should use doc text over handler name")
+	})
+
+	t.Run("empty strings before valid", func(t *testing.T) {
+		result := getFirstUsableString("", "", "valid")
+		assert.Equal(t, "valid", result, "should skip empty strings")
+	})
+
+	t.Run("all empty strings", func(t *testing.T) {
+		result := getFirstUsableString("", "", "")
+		assert.Equal(t, "", result, "should return empty if all are empty")
+	})
+
+	t.Run("quoted then plain", func(t *testing.T) {
+		result := getFirstUsableString(`"quoted"`, "plain")
+		assert.Equal(t, "quoted", result, "should unquote first quoted string")
+	})
+
+	t.Run("plain then quoted", func(t *testing.T) {
+		result := getFirstUsableString("plain", `"quoted"`)
+		assert.Equal(t, "plain", result, "should use first plain string")
+	})
+
+	t.Run("invalid quoted string", func(t *testing.T) {
+		// String that looks quoted but isn't valid Go syntax
+		result := getFirstUsableString(`"incomplete`, "fallback")
+		assert.Equal(t, `"incomplete`, result, "should use as-is if unquote fails but not empty")
+	})
+
+	t.Run("whitespace only", func(t *testing.T) {
+		result := getFirstUsableString("   ", "fallback")
+		assert.Equal(t, "   ", result, "should not trim whitespace, return as-is")
+	})
+
+	t.Run("real world API doc scenario", func(t *testing.T) {
+		// This is the actual bug scenario from issue #5229
+		atDocText := "Check server health status."
+		handlerName := "HealthCheck"
+		
+		result := getFirstUsableString(atDocText, handlerName)
+		assert.Equal(t, "Check server health status.", result, 
+			"should use @doc text for API summary")
+	})
+
+	t.Run("real world with empty doc", func(t *testing.T) {
+		// When @doc is empty, should fall back to handler name
+		atDocText := ""
+		handlerName := "HealthCheck"
+		
+		result := getFirstUsableString(atDocText, handlerName)
+		assert.Equal(t, "HealthCheck", result, 
+			"should fallback to handler name when @doc is empty")
+	})
+
+	t.Run("complex summary with special characters", func(t *testing.T) {
+		result := getFirstUsableString("Get user by ID: /users/{id}")
+		assert.Equal(t, "Get user by ID: /users/{id}", result, 
+			"should handle special characters in plain strings")
+	})
+
+	t.Run("multiline string", func(t *testing.T) {
+		result := getFirstUsableString("Line 1\nLine 2")
+		assert.Equal(t, "Line 1\nLine 2", result, 
+			"should handle multiline strings")
+	})
+
+	t.Run("unicode characters", func(t *testing.T) {
+		result := getFirstUsableString("健康检查", "HealthCheck")
+		assert.Equal(t, "健康检查", result, 
+			"should handle unicode characters")
+	})
+}
+