Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,9 @@ from normal help: `--format`, `--source`, `--include-test-files`,

The generator reads regular Go comments from source files to fill operation
summaries, operation descriptions, and schema field descriptions. It does not
require `doc` tags.
require `doc` tags. Handler comments can also include an `openapi:` block for
operation-level metadata; when `summary` or `description` are omitted from the
block, the regular handler comment still provides them.

```go
type CreateUserRequest struct {
Expand All @@ -291,11 +293,20 @@ type CreateUserRequest struct {
// Create user.
//
// Creates a user and returns the persisted representation.
//
// openapi:
// x-public: true
// x-audience: external
func createUser(ctx *fox.Context, req CreateUserRequest) (UserResponse, error) {
return UserResponse{}, nil
}
```

The `openapi:` block is removed from the generated description. It supports
OpenAPI extension fields such as `x-public` and `x-audience`, plus simple
operation fields such as `summary`, `description`, `operationId`, `tags`, and
`deprecated`.

For metadata that needs Go values, add a small optional hook:

```go
Expand Down Expand Up @@ -423,5 +434,5 @@ For manifest mode, refresh the application-owned manifest before generating:

The current implementation intentionally does not generate DomainEngine-specific
multi-host specs, custom schema naming overrides, or operation/group tag
assignment directly from YAML config. Use `metadataHook` for route-specific
metadata.
assignment directly from YAML config. Use handler comment `openapi:` blocks for
simple operation metadata and `metadataHook` when metadata needs Go values.
21 changes: 16 additions & 5 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,22 +240,33 @@ fox-openapi version

## Metadata

生成器会读取源码中的普通 Go 注释,用来填充 operation summary、operation description 和 schema field description。不需要额外的 `doc` tag。
生成器会读取源码中的普通 Go 注释,用来填充 operation summary、operation
description 和 schema field description。不需要额外的 `doc` tag。handler 注释也可以包含
`openapi:` 块来提供 operation 级 metadata;如果块里没有写 `summary` 或
`description`,仍会使用 handler 原本的普通注释。

```go
type CreateUserRequest struct {
// Display name for the new user.
// 新用户的展示名称。
Name string `json:"name" binding:"required"`
}

// Create user.
// 创建用户。
//
// Creates a user and returns the persisted representation.
// 创建用户并返回持久化后的表示。
//
// openapi:
// x-public: true
// x-audience: external
func createUser(ctx *fox.Context, req CreateUserRequest) (UserResponse, error) {
return UserResponse{}, nil
}
```

`openapi:` 块不会出现在生成后的 description 中。它支持 `x-public`、
`x-audience` 等 OpenAPI extension 字段,以及 `summary`、`description`、
`operationId`、`tags`、`deprecated` 等简单 operation 字段。

如果 metadata 需要 Go value,可以添加一个小的可选 hook:

```go
Expand Down Expand Up @@ -374,4 +385,4 @@ Manifest 模式下,先刷新业务应用负责的 manifest,再生成 OpenAPI

## 当前限制

当前实现有意不生成 DomainEngine 专用的多 host specs、自定义 schema 命名覆盖,也不支持直接从 YAML 配置为 operation 或 group 分配 tags。路由级 metadata 请使用 `metadataHook`。
当前实现有意不生成 DomainEngine 专用的多 host specs、自定义 schema 命名覆盖,也不支持直接从 YAML 配置为 operation 或 group 分配 tags。简单 operation metadata 可使用 handler 注释里的 `openapi:` 块;需要 Go value 时请使用 `metadataHook`。
205 changes: 194 additions & 11 deletions comments.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package openapi

import (
"fmt"
"go/ast"
"go/parser"
"go/token"
Expand All @@ -9,6 +10,8 @@ import (
"path/filepath"
"strconv"
"strings"

"github.com/goccy/go-yaml"
)

// commentDocs holds Go doc comments extracted from source so they can enrich
Expand All @@ -19,17 +22,28 @@ import (
// Field lookup is keyed by (typeName, fieldName) — typeName uses the package
// name as it appears in source (the runtime PkgPath last segment).
type commentDocs struct {
funcsByQualified map[string]string
funcsByShort map[string]string
funcsByQualified map[string]funcComment
funcsByShort map[string]funcComment
statusByQualified map[string]int
fieldsByType map[string]map[string]string
warnings []string
includeTests bool
}

type funcComment struct {
text string
doc operationDoc
}

type openAPICommentBlock struct {
HumanDocumentation string
OpenAPIMetadata string
}

func newCommentDocs() *commentDocs {
return &commentDocs{
funcsByQualified: make(map[string]string),
funcsByShort: make(map[string]string),
funcsByQualified: make(map[string]funcComment),
funcsByShort: make(map[string]funcComment),
statusByQualified: make(map[string]int),
fieldsByType: make(map[string]map[string]string),
}
Expand Down Expand Up @@ -144,10 +158,13 @@ func (d *commentDocs) addFunc(pkgName string, decl *ast.FuncDecl, imports map[st
}

if decl.Doc != nil {
text := commentText(decl.Doc)
d.funcsByQualified[qualified] = text
comment, err := parseFuncComment(commentText(decl.Doc))
if err != nil {
d.warnings = append(d.warnings, fmt.Sprintf("invalid openapi comment block for %s: %v", qualified, err))
}
d.funcsByQualified[qualified] = comment
// Short name is best-effort fallback — last writer wins on collision.
d.funcsByShort[short] = text
d.funcsByShort[short] = comment
}

if status, ok := inferredReturnStatus(decl, imports); ok {
Expand Down Expand Up @@ -236,18 +253,35 @@ func fieldCommentText(field *ast.Field) string {
// qualified name first (after stripping -fm and .funcN suffixes) and falls
// back to the trailing identifier.
func (d *commentDocs) funcDoc(runtimeName string) string {
if d == nil || runtimeName == "" {
comment, ok := d.funcComment(runtimeName)
if !ok {
return ""
}
return comment.text
}

func (d *commentDocs) funcOperationDoc(runtimeName string) (operationDoc, bool) {
comment, ok := d.funcComment(runtimeName)
if !ok {
return operationDoc{}, false
}
return comment.doc, !comment.doc.empty()
}

func (d *commentDocs) funcComment(runtimeName string) (funcComment, bool) {
if d == nil || runtimeName == "" {
return funcComment{}, false
}
qualified := normalizeRuntimeFuncName(runtimeName)
if text, ok := d.funcsByQualified[qualified]; ok {
return text
if comment, ok := d.funcsByQualified[qualified]; ok {
return comment, true
}
short := qualified
if idx := strings.LastIndex(short, "."); idx >= 0 {
short = short[idx+1:]
}
return d.funcsByShort[short]
comment, ok := d.funcsByShort[short]
return comment, ok
}

func (d *commentDocs) returnStatus(runtimeName string) (int, bool) {
Expand Down Expand Up @@ -304,6 +338,155 @@ func commentText(group *ast.CommentGroup) string {
return strings.TrimSpace(group.Text())
}

func parseFuncComment(text string) (funcComment, error) {
block := splitOpenAPICommentBlock(text)
comment := funcComment{text: block.HumanDocumentation}
if block.OpenAPIMetadata == "" {
return comment, nil
}

var values map[string]any
if err := yaml.Unmarshal([]byte(block.OpenAPIMetadata), &values); err != nil {
return comment, err
}
comment.doc = operationDocFromCommentBlock(values)
return comment, nil
}

func splitOpenAPICommentBlock(text string) openAPICommentBlock {
lines := strings.Split(text, "\n")
humanLines := make([]string, 0, len(lines))
metadataLines := make([]string, 0, len(lines))
inBlock := false

for _, line := range lines {
trimmed := strings.TrimSpace(line)
if !inBlock && trimmed == "openapi:" {
inBlock = true
continue
}
if !inBlock {
humanLines = append(humanLines, line)
continue
}
if trimmed == "" {
metadataLines = append(metadataLines, "")
continue
}
if line == strings.TrimLeft(line, " \t") {
humanLines = append(humanLines, line)
inBlock = false
continue
}
metadataLines = append(metadataLines, line)
}

metadataLines = dedentLines(metadataLines)
return openAPICommentBlock{
HumanDocumentation: strings.TrimSpace(strings.Join(humanLines, "\n")),
OpenAPIMetadata: strings.TrimSpace(strings.Join(metadataLines, "\n")),
}
}

func dedentLines(lines []string) []string {
indent := commonIndent(lines)
if indent == "" {
return lines
}
out := make([]string, len(lines))
for i, line := range lines {
out[i] = strings.TrimPrefix(line, indent)
}
return out
}

func commonIndent(lines []string) string {
prefix := ""
found := false
for _, line := range lines {
if strings.TrimSpace(line) == "" {
continue
}
indent := leadingWhitespace(line)
if !found {
prefix = indent
found = true
continue
}
prefix = commonPrefix(prefix, indent)
}
return prefix
}

func leadingWhitespace(line string) string {
return line[:len(line)-len(strings.TrimLeft(line, " \t"))]
}

func commonPrefix(a, b string) string {
max := len(a)
if len(b) < max {
max = len(b)
}
for i := 0; i < max; i++ {
if a[i] != b[i] {
return a[:i]
}
}
return a[:max]
}

func operationDocFromCommentBlock(values map[string]any) operationDoc {
doc := operationDoc{}
for key, value := range values {
switch key {
case "summary":
if text, ok := value.(string); ok {
doc.Summary = text
}
case "description":
if text, ok := value.(string); ok {
doc.Description = text
}
case "operationId":
if text, ok := value.(string); ok {
doc.OperationID = text
}
case "tags":
if tags, ok := stringSlice(value); ok {
doc.Tags = tags
}
case "deprecated":
if deprecated, ok := value.(bool); ok {
doc.Deprecated = &deprecated
}
default:
if strings.HasPrefix(key, "x-") {
if doc.Extensions == nil {
doc.Extensions = make(map[string]any)
}
doc.Extensions[key] = value
}
}
}
return doc
}

func stringSlice(value any) ([]string, bool) {
values, ok := value.([]any)
if !ok {
return nil, false
}
out := make([]string, 0, len(values))
for _, item := range values {
text, ok := item.(string)
if !ok {
return nil, false
}
out = append(out, text)
}
return out, true
}

func firstParagraph(text string) string {
if idx := strings.Index(text, "\n\n"); idx >= 0 {
return text[:idx]
Expand Down
Loading
Loading