Skip to content

Commit c9ad203

Browse files
committed
feat: generate openapi from route manifest
1 parent c6e838a commit c9ad203

16 files changed

Lines changed: 1647 additions & 48 deletions

README.md

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,35 @@ temporary dependency and restores `go.mod`/`go.sum` afterward. Add a direct
8888
requirement only when application code imports OpenAPI metadata hooks or
8989
library APIs.
9090

91+
Alternatively, a Fox application can export a route manifest and let
92+
fox-openapi consume that file:
93+
94+
```go
95+
if *routeManifestPath != "" {
96+
if err := fox.WriteRouteManifest(engine, *routeManifestPath); err != nil {
97+
log.Fatal(err)
98+
}
99+
return
100+
}
101+
```
102+
103+
```yaml
104+
routeManifest: api/routes.manifest.json
105+
```
106+
107+
```bash
108+
# First ask the application to write or refresh the manifest.
109+
myapp --openapi-route-manifest api/routes.manifest.json
110+
111+
# Then ask fox-openapi to read the manifest and write the OpenAPI document.
112+
fox-openapi generate --route-manifest api/routes.manifest.json --out api/openapi.yaml
113+
```
114+
115+
Manifest mode does not run the application entry and does not update the
116+
manifest file. It uses the existing manifest for methods, paths, handler
117+
identities, path parameters, operation IDs, request/response schemas, and source
118+
comment enrichment.
119+
91120
## Entry Functions
92121

93122
`entry` must name an exported function with one of these signatures:
@@ -101,17 +130,28 @@ func NewEngine(context.Context, *Config) *fox.Engine
101130
func NewEngine(context.Context, *Config) (*fox.Engine, error)
102131
```
103132

104-
For config-taking entries, omit `entryConfig` to pass `nil` as the config
105-
argument, or provide a loader:
133+
For config-taking entries, provide an `entryConfig.path` and fox-openapi will
134+
use the entry config type's package-level `Load(string) (*Config, error)`
135+
function when it exists:
136+
137+
```yaml
138+
entryConfig:
139+
path: config.yaml
140+
```
141+
142+
Use `entryConfig.loader` only when the loader is not the standard `Load`
143+
function or lives outside the config package:
106144

107145
```yaml
108146
entryConfig:
109-
loader: github.com/acme/myapp/internal/config.Load
147+
loader: github.com/acme/myapp/internal/config.LoadForOpenAPI
110148
path: config.yaml
111149
```
112150

113-
Passing `nil` lets production code share one route-registration entry with
114-
OpenAPI generation without initializing databases or external providers.
151+
This keeps the normal production `NewEngine(context.Context, *Config)` usable
152+
for OpenAPI generation without adding route-only branches just for the tool.
153+
When `entryConfig` is omitted entirely, fox-openapi still passes `nil` for
154+
compatibility with existing projects.
115155

116156
## Path resolution
117157

README.zh-CN.md

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,32 @@ metadata 时,才需要使用 `fox-openapi init` 创建配置文件。
7070

7171
CLI 会构建一个隔离的临时 driver。基础生成场景下,业务模块不需要 `tools.go` 文件,也不需要提交直接的 `github.com/fox-gonic/openapi` 依赖;driver 构建会解析这个临时依赖,并在结束后恢复 `go.mod` / `go.sum`。只有业务代码自己 import OpenAPI metadata hook 或 library API 时,才需要直接声明依赖。
7272

73+
也可以让 Fox 应用先导出 route manifest,再由 fox-openapi 读取这个文件:
74+
75+
```go
76+
if *routeManifestPath != "" {
77+
if err := fox.WriteRouteManifest(engine, *routeManifestPath); err != nil {
78+
log.Fatal(err)
79+
}
80+
return
81+
}
82+
```
83+
84+
```yaml
85+
routeManifest: api/routes.manifest.json
86+
```
87+
88+
```bash
89+
# 先让业务应用写入或刷新 manifest。
90+
myapp --openapi-route-manifest api/routes.manifest.json
91+
92+
# 再让 fox-openapi 读取 manifest 并写出 OpenAPI 文档。
93+
fox-openapi generate --route-manifest api/routes.manifest.json --out api/openapi.yaml
94+
```
95+
96+
Manifest 模式不会运行应用 entry,也不会更新 manifest 文件。它会使用已有 manifest
97+
中的方法、路径、handler 标识、path 参数、operationId、request / response schema,并继续结合源码注释补全文档。
98+
7399
## Entry 函数
74100

75101
`entry` 必须指向一个导出函数,并符合以下签名之一:
@@ -83,15 +109,26 @@ func NewEngine(context.Context, *Config) *fox.Engine
83109
func NewEngine(context.Context, *Config) (*fox.Engine, error)
84110
```
85111

86-
对于接收配置的 entry,可以省略 `entryConfig`,此时 CLI 会把配置参数传为 `nil`;也可以提供一个配置 loader:
112+
对于接收配置的 entry,提供 `entryConfig.path` 后,fox-openapi 会优先在 entry
113+
的配置类型所在包中自动使用包级 `Load(string) (*Config, error)` 函数:
114+
115+
```yaml
116+
entryConfig:
117+
path: config.yaml
118+
```
119+
120+
只有当 loader 不是标准 `Load`,或不在配置类型所在包中时,才需要显式指定
121+
`entryConfig.loader`:
87122

88123
```yaml
89124
entryConfig:
90-
loader: github.com/acme/myapp/internal/config.Load
125+
loader: github.com/acme/myapp/internal/config.LoadForOpenAPI
91126
path: config.yaml
92127
```
93128

94-
传入 `nil` 可以让生产代码和 OpenAPI 生成共用同一个路由注册入口,同时避免初始化数据库或外部服务。
129+
这样 OpenAPI 生成可以直接复用正常的生产
130+
`NewEngine(context.Context, *Config)`,不需要为了工具额外添加 route-only 分支。
131+
为了兼容已有项目,完全省略 `entryConfig` 时,fox-openapi 仍会传入 `nil`。
95132

96133
## 路径解析
97134

cmd/fox-openapi/main.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ func runGenerate(cmd *cobra.Command, opts *commonOptions, args []string) error {
144144
return exitError{code: cli.ExitWriteFailed, err: fmt.Errorf("write %s: %w", out, err)}
145145
}
146146
fmt.Printf("wrote %s (%s, %d bytes)\n", out, strings.ToUpper(cfg.Format), len(data))
147-
fmt.Printf(" entry: %s%s\n", cfg.Entry, autoTag(cfg.EntryAutoDiscovered))
147+
printInputSummary(cfg)
148148
return nil
149149
}
150150

@@ -176,7 +176,7 @@ func newCheckCommand() *cobra.Command {
176176
return exitError{code: cli.ExitWriteFailed, err: fmt.Errorf("check %s: %w", out, err)}
177177
}
178178
fmt.Printf("%s is up to date.\n", out)
179-
fmt.Printf(" entry: %s%s\n", cfg.Entry, autoTag(cfg.EntryAutoDiscovered))
179+
printInputSummary(cfg)
180180
return nil
181181
},
182182
}
@@ -307,8 +307,9 @@ func bindCommonFlags(flags *pflag.FlagSet, opts *commonOptions) {
307307
flags.Var(&opts.sources, "source", "source path")
308308
flags.BoolVar(&o.IncludeTestFiles, "include-test-files", false, "include *_test.go")
309309
flags.StringVar(&o.MetadataHook, "metadata-hook", "", "metadata hook")
310-
flags.StringVar(&o.EntryConfigLoader, "entry-config-loader", "", "entry config loader")
310+
flags.StringVar(&o.EntryConfigLoader, "entry-config-loader", "", "entry config loader (optional when config package has Load)")
311311
flags.StringVar(&o.EntryConfigPath, "entry-config-path", "", "entry config path")
312+
flags.StringVar(&o.RouteManifest, "route-manifest", "", "Fox route manifest path")
312313
flags.StringVar(&o.Workdir, "workdir", ".", "user project root")
313314
flags.BoolVar(&o.KeepDriver, "keep-driver", false, "keep generated driver")
314315
flags.BoolVar(&o.Verbose, "verbose", false, "verbose output")
@@ -318,6 +319,7 @@ func bindCommonFlags(flags *pflag.FlagSet, opts *commonOptions) {
318319
"metadata-hook",
319320
"entry-config-loader",
320321
"entry-config-path",
322+
"route-manifest",
321323
"keep-driver",
322324
"verbose",
323325
"format",
@@ -415,6 +417,8 @@ func markOverride(o *cli.Overrides, name string) {
415417
o.EntryConfigLoaderSet = true
416418
case "entry-config-path":
417419
o.EntryConfigPathSet = true
420+
case "route-manifest":
421+
o.RouteManifestSet = true
418422
case "workdir":
419423
o.WorkdirSet = true
420424
case "keep-driver":
@@ -443,6 +447,14 @@ func autoTag(autoDiscovered bool) string {
443447
return ""
444448
}
445449

450+
func printInputSummary(cfg cli.Config) {
451+
if cfg.RouteManifest != "" {
452+
fmt.Printf(" route manifest: %s\n", cfg.RouteManifest)
453+
return
454+
}
455+
fmt.Printf(" entry: %s%s\n", cfg.Entry, autoTag(cfg.EntryAutoDiscovered))
456+
}
457+
446458
type repeatedFlag struct {
447459
values []string
448460
set bool

internal/cli/config.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ type Config struct {
3636
SecuritySchemes map[string]Scheme `yaml:"securitySchemes"`
3737
MetadataHook string `yaml:"metadataHook"`
3838
EntryConfig EntryConfig `yaml:"entryConfig"`
39+
RouteManifest string `yaml:"routeManifest"`
3940
Workdir string `yaml:"workdir"`
4041
KeepDriver bool `yaml:"keepDriver"`
4142
Verbose bool `yaml:"verbose"`
@@ -133,6 +134,8 @@ type Overrides struct {
133134
EntryConfigLoaderSet bool
134135
EntryConfigPath string
135136
EntryConfigPathSet bool
137+
RouteManifest string
138+
RouteManifestSet bool
136139
Workdir string
137140
WorkdirSet bool
138141
KeepDriver bool
@@ -214,7 +217,7 @@ func LoadConfig(overrides Overrides) (Config, error) {
214217
if cfg.Format != FormatYAML && cfg.Format != FormatJSON {
215218
return Config{}, fmt.Errorf("format must be yaml or json, got %q", cfg.Format)
216219
}
217-
if cfg.Entry == "" {
220+
if cfg.Entry == "" && cfg.RouteManifest == "" {
218221
// Discovery scope: explicit position arg > Sources > "./..." default.
219222
// Comment extraction (Sources) stays module-wide so referenced types
220223
// keep their field docs.
@@ -334,6 +337,9 @@ func mergeFromFile(cfg *Config, fileCfg Config, configDir string) {
334337
if fileCfg.EntryConfig.Path != "" {
335338
cfg.EntryConfig.Path = resolveRelative(configDir, fileCfg.EntryConfig.Path)
336339
}
340+
if fileCfg.RouteManifest != "" {
341+
cfg.RouteManifest = resolveRelative(configDir, fileCfg.RouteManifest)
342+
}
337343
if fileCfg.Workdir != "" {
338344
cfg.Workdir = resolveRelative(configDir, fileCfg.Workdir)
339345
}
@@ -392,6 +398,9 @@ func applyOverrides(cfg *Config, o Overrides, cwd string) {
392398
if o.EntryConfigPathSet {
393399
cfg.EntryConfig.Path = resolveRelative(cwd, o.EntryConfigPath)
394400
}
401+
if o.RouteManifestSet {
402+
cfg.RouteManifest = resolveRelative(cwd, o.RouteManifest)
403+
}
395404
if o.WorkdirSet {
396405
cfg.Workdir = resolveRelative(cwd, o.Workdir)
397406
}

internal/cli/config_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,25 @@ func TestLoadConfigMissingFileUsesDefaults(t *testing.T) {
8787
}
8888
}
8989

90+
func TestLoadConfigRouteManifestDoesNotRequireEntry(t *testing.T) {
91+
dir := t.TempDir()
92+
configPath := filepath.Join(dir, "fox-openapi.yaml")
93+
if err := os.WriteFile(configPath, []byte(`
94+
routeManifest: api/routes.manifest.json
95+
out: api/openapi.yaml
96+
`), 0o644); err != nil {
97+
t.Fatal(err)
98+
}
99+
100+
cfg, err := LoadConfig(Overrides{ConfigPath: configPath, ConfigExplicit: true})
101+
if err != nil {
102+
t.Fatal(err)
103+
}
104+
if cfg.Entry != "" || cfg.RouteManifest != filepath.Join(dir, "api/routes.manifest.json") {
105+
t.Fatalf("unexpected config: entry=%q routeManifest=%q", cfg.Entry, cfg.RouteManifest)
106+
}
107+
}
108+
90109
func TestLoadConfigInvalidFormat(t *testing.T) {
91110
_, err := LoadConfig(Overrides{
92111
Entry: "example.com/app.NewEngine",

internal/cli/discover.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -142,11 +142,13 @@ func entryFromFunc(importPath string, obj *types.Func) (Entry, bool) {
142142
return Entry{}, false
143143
}
144144
return Entry{
145-
ImportPath: importPath,
146-
FuncName: obj.Name(),
147-
TakesContext: shape.takesContext,
148-
TakesConfig: shape.takesConfig,
149-
ReturnsError: shape.returnsError,
145+
ImportPath: importPath,
146+
FuncName: obj.Name(),
147+
TakesContext: shape.takesContext,
148+
TakesConfig: shape.takesConfig,
149+
ConfigImportPath: shape.configImportPath,
150+
ConfigTypeName: shape.configTypeName,
151+
ReturnsError: shape.returnsError,
150152
}, true
151153
}
152154

0 commit comments

Comments
 (0)