diff --git a/README.md b/README.md index fb4c677..bbc2bf9 100644 --- a/README.md +++ b/README.md @@ -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 { @@ -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 @@ -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. diff --git a/README.zh-CN.md b/README.zh-CN.md index 485074b..0881b96 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 @@ -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`。 diff --git a/comments.go b/comments.go index 19e584e..fc3b1c7 100644 --- a/comments.go +++ b/comments.go @@ -1,6 +1,7 @@ package openapi import ( + "fmt" "go/ast" "go/parser" "go/token" @@ -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 @@ -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), } @@ -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 { @@ -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) { @@ -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] diff --git a/openapi.go b/openapi.go index e081562..0f2b62f 100644 --- a/openapi.go +++ b/openapi.go @@ -121,6 +121,7 @@ func (g *Generator) ensureGenerated() { if g.generated { return } + g.addSourceWarnings() g.addHTTPErrorSchema() g.generate() g.generated = true @@ -137,6 +138,15 @@ func (g *Generator) warnf(format string, args ...any) { g.warnings = append(g.warnings, fmt.Sprintf(format, args...)) } +func (g *Generator) addSourceWarnings() { + if g.docs == nil { + return + } + for _, warning := range g.docs.warnings { + g.warnf("%s", warning) + } +} + // JSON serializes the generated spec as formatted JSON. func (g *Generator) JSON() ([]byte, error) { g.ensureGenerated() @@ -179,12 +189,7 @@ func (g *Generator) generateManifestRoute(route RouteManifestRoute) { op.OperationID = sanitizeName(route.Method + "_" + route.Path) } op.Responses = openapi3.NewResponses() - if g.docs != nil { - if text := g.docs.funcDoc(route.HandlerSymbol()); text != "" { - op.Summary = firstParagraph(text) - op.Description = text - } - } + g.applyHandlerDoc(op, route.HandlerSymbol()) if input, ok := manifestRequestBody(route); ok { g.addManifestInput(op, route, input) @@ -212,6 +217,7 @@ func (g *Generator) generateManifestRoute(route RouteManifestRoute) { if manifestRouteReturnsError(route) { op.Responses.Set("default", &openapi3.ResponseRef{Ref: "#/components/responses/HTTPError"}) } + g.applyOperationDoc(op, route.Method, route.Path) g.spec.AddOperation(openAPIPath(route.Path), route.Method, op) } @@ -620,12 +626,7 @@ func (g *Generator) generateRoute(route fox.RouteInfo) { op := openapi3.NewOperation() op.OperationID = operationID(route) op.Responses = openapi3.NewResponses() - if g.docs != nil { - if text := g.docs.funcDoc(route.HandlerName); text != "" { - op.Summary = firstParagraph(text) - op.Description = text - } - } + g.applyHandlerDoc(op, route.HandlerName) if route.HandlerType.NumIn() == 2 { g.addInput(op, route, route.HandlerType.In(1)) @@ -637,6 +638,19 @@ func (g *Generator) generateRoute(route fox.RouteInfo) { g.spec.AddOperation(openAPIPath(route.Path), route.Method, op) } +func (g *Generator) applyHandlerDoc(op *openapi3.Operation, handlerName string) { + if g.docs == nil { + return + } + if text := g.docs.funcDoc(handlerName); text != "" { + op.Summary = firstParagraph(text) + op.Description = text + } + if doc, ok := g.docs.funcOperationDoc(handlerName); ok { + g.applyDoc(op, doc) + } +} + func (g *Generator) addMissingPathParams(op *openapi3.Operation, path string) { for name := range pathParamNames(path) { if hasParameter(op, name, "path") { diff --git a/openapi_test.go b/openapi_test.go index ecb8237..57af37b 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -152,6 +152,40 @@ func createDocumentedUser(_ *fox.Context, _ documentedCreateUserRequest) documen return documentedUserResponse{} } +// Create public documented user. +// +// Creates a user and returns the persisted representation. +// +// openapi: +// +// x-public: true +// x-audience: external +func createPublicDocumentedUser(_ *fox.Context, _ documentedCreateUserRequest) documentedUserResponse { + return documentedUserResponse{} +} + +// Create rate limited documented user. +// +// Creates a user with nested OpenAPI extension metadata. +// +// openapi: +// +// x-rate-limit: +// tier: public +// burst: 10 +func createRateLimitedDocumentedUser(_ *fox.Context, _ documentedCreateUserRequest) documentedUserResponse { + return documentedUserResponse{} +} + +// Create invalid documented user. +// +// openapi: +// +// x-public: [broken +func createInvalidOpenAPICommentUser(_ *fox.Context, _ documentedCreateUserRequest) documentedUserResponse { + return documentedUserResponse{} +} + func createDocumentedUserWithStatus(_ *fox.Context, _ documentedCreateUserRequest) (statusResponse[documentedUserResponse], error) { return statusResponseWithStatus(http.StatusCreated, documentedUserResponse{}), nil } @@ -497,6 +531,68 @@ func TestGenerateReadsHandlerAndFieldCommentsFromSource(t *testing.T) { require.Equal(t, "Stable user identifier.", responseProps["id"].(map[string]any)["description"]) } +func TestGenerateReadsOperationExtensionsFromOpenAPICommentBlock(t *testing.T) { + engine := fox.New() + engine.POST("/public-documented-users", createPublicDocumentedUser) + engine.POST("/rate-limited-documented-users", createRateLimitedDocumentedUser) + + g := openapi.New(engine, + openapi.Info("Fox Test API", "1.0.0"), + openapi.Source([]string{"./..."}, openapi.IncludeTestFiles()), + ) + + data, err := g.JSON() + require.NoError(t, err) + + var spec map[string]any + require.NoError(t, json.Unmarshal(data, &spec)) + + op := spec["paths"].(map[string]any)["/public-documented-users"].(map[string]any)["post"].(map[string]any) + require.Equal(t, "Create public documented user.", op["summary"]) + require.Equal(t, "Create public documented user.\n\nCreates a user and returns the persisted representation.", op["description"]) + require.Equal(t, true, op["x-public"]) + require.Equal(t, "external", op["x-audience"]) + require.NotContains(t, op["description"], "openapi:") + + rateLimitedOp := spec["paths"].(map[string]any)["/rate-limited-documented-users"].(map[string]any)["post"].(map[string]any) + rateLimit := rateLimitedOp["x-rate-limit"].(map[string]any) + require.Equal(t, "public", rateLimit["tier"]) + require.Equal(t, float64(10), rateLimit["burst"]) +} + +func TestGenerateWarnsForInvalidOpenAPICommentBlock(t *testing.T) { + engine := fox.New() + engine.POST("/invalid-openapi-comment", createInvalidOpenAPICommentUser) + + g := openapi.New(engine, + openapi.Info("Fox Test API", "1.0.0"), + openapi.Source([]string{"./..."}, openapi.IncludeTestFiles()), + ) + + _, err := g.JSON() + require.NoError(t, err) + + require.Len(t, g.Warnings(), 1) + require.Contains(t, g.Warnings()[0], "openapi_test.createInvalidOpenAPICommentUser") + require.Contains(t, g.Warnings()[0], "invalid openapi comment block") +} + +func TestRegenerateRetainsInvalidOpenAPICommentBlockWarning(t *testing.T) { + engine := fox.New() + engine.POST("/invalid-openapi-comment", createInvalidOpenAPICommentUser) + + g := openapi.New(engine, + openapi.Info("Fox Test API", "1.0.0"), + openapi.Source([]string{"./..."}, openapi.IncludeTestFiles()), + ) + require.Len(t, g.Warnings(), 1) + + g.Regenerate() + + require.Len(t, g.Warnings(), 1) + require.Contains(t, g.Warnings()[0], "openapi_test.createInvalidOpenAPICommentUser") +} + func TestGenerateReadsFieldLineCommentsFromSource(t *testing.T) { engine := fox.New() engine.GET("/line-comment", getLineComment) diff --git a/operation.go b/operation.go index 4b41cb8..17f16f5 100644 --- a/operation.go +++ b/operation.go @@ -20,6 +20,7 @@ type operationDoc struct { OperationID string Tags []string Deprecated *bool + Extensions map[string]any Responses map[int]responseDoc Security openapi3.SecurityRequirements } @@ -156,6 +157,17 @@ func hasSuccessResponse(doc operationDoc) bool { return false } +func (doc operationDoc) empty() bool { + return doc.Summary == "" && + doc.Description == "" && + doc.OperationID == "" && + len(doc.Tags) == 0 && + doc.Deprecated == nil && + len(doc.Extensions) == 0 && + len(doc.Responses) == 0 && + len(doc.Security) == 0 +} + func (g *Generator) applyDoc(op *openapi3.Operation, doc operationDoc) { if doc.Summary != "" { op.Summary = doc.Summary @@ -175,6 +187,14 @@ func (g *Generator) applyDoc(op *openapi3.Operation, doc operationDoc) { if len(doc.Security) > 0 { op.Security = &doc.Security } + if len(doc.Extensions) > 0 { + if op.Extensions == nil { + op.Extensions = make(map[string]any) + } + for key, value := range doc.Extensions { + op.Extensions[key] = value + } + } for status, response := range doc.Responses { op.Responses.Set(strconv.Itoa(status), g.explicitResponse(status, response)) } diff --git a/route_manifest_test.go b/route_manifest_test.go index 6287c1c..7407ad9 100644 --- a/route_manifest_test.go +++ b/route_manifest_test.go @@ -21,6 +21,14 @@ func manifestStatusResponseWithStatus[T any](status int, data T) manifestStatusR } } +// Create manifest template. +// +// Creates a template from a route manifest. +// +// openapi: +// +// x-public: true +// x-audience: external func createManifestTemplateForRouteManifest() (manifestStatusResponse[manifestTemplatePayload], error) { return manifestStatusResponseWithStatus(http.StatusAccepted, manifestTemplatePayload{}), nil } @@ -146,6 +154,19 @@ func TestNewFromRouteManifestUnwrapsStatusResponseBody(t *testing.T) { if response == nil { t.Fatalf("missing 202 response") } + operation := spec.Paths.Value("/sandbox/templates").Post + if operation.Summary != "Create manifest template." { + t.Fatalf("summary = %q", operation.Summary) + } + if operation.Description != "Create manifest template.\n\nCreates a template from a route manifest." { + t.Fatalf("description = %q", operation.Description) + } + if operation.Extensions["x-public"] != true { + t.Fatalf("x-public = %#v", operation.Extensions["x-public"]) + } + if operation.Extensions["x-audience"] != "external" { + t.Fatalf("x-audience = %#v", operation.Extensions["x-audience"]) + } content := response.Value.Content.Get("application/json") if content == nil { t.Fatalf("missing response content")