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
50 changes: 45 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,35 @@ temporary dependency and restores `go.mod`/`go.sum` afterward. Add a direct
requirement only when application code imports OpenAPI metadata hooks or
library APIs.

Alternatively, a Fox application can export a route manifest and let
fox-openapi consume that file:

```go
if *routeManifestPath != "" {
if err := fox.WriteRouteManifest(engine, *routeManifestPath); err != nil {
log.Fatal(err)
}
return
}
```

```yaml
routeManifest: api/routes.manifest.json
```

```bash
# First ask the application to write or refresh the manifest.
myapp --openapi-route-manifest api/routes.manifest.json

# Then ask fox-openapi to read the manifest and write the OpenAPI document.
fox-openapi generate --route-manifest api/routes.manifest.json --out api/openapi.yaml
```

Manifest mode does not run the application entry and does not update the
manifest file. It uses the existing manifest for methods, paths, handler
identities, path parameters, operation IDs, request/response schemas, and source
comment enrichment.

## Entry Functions

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

For config-taking entries, omit `entryConfig` to pass `nil` as the config
argument, or provide a loader:
For config-taking entries, provide an `entryConfig.path` and fox-openapi will
use the entry config type's package-level `Load(string) (*Config, error)`
function when it exists:

```yaml
entryConfig:
path: config.yaml
```

Use `entryConfig.loader` only when the loader is not the standard `Load`
function or lives outside the config package:

```yaml
entryConfig:
loader: github.com/acme/myapp/internal/config.Load
loader: github.com/acme/myapp/internal/config.LoadForOpenAPI
path: config.yaml
```

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

## Path resolution

Expand Down
43 changes: 40 additions & 3 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,32 @@ metadata 时,才需要使用 `fox-openapi init` 创建配置文件。

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

也可以让 Fox 应用先导出 route manifest,再由 fox-openapi 读取这个文件:

```go
if *routeManifestPath != "" {
if err := fox.WriteRouteManifest(engine, *routeManifestPath); err != nil {
log.Fatal(err)
}
return
}
```

```yaml
routeManifest: api/routes.manifest.json
```

```bash
# 先让业务应用写入或刷新 manifest。
myapp --openapi-route-manifest api/routes.manifest.json

# 再让 fox-openapi 读取 manifest 并写出 OpenAPI 文档。
fox-openapi generate --route-manifest api/routes.manifest.json --out api/openapi.yaml
```

Manifest 模式不会运行应用 entry,也不会更新 manifest 文件。它会使用已有 manifest
中的方法、路径、handler 标识、path 参数、operationId、request / response schema,并继续结合源码注释补全文档。

## Entry 函数

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

对于接收配置的 entry,可以省略 `entryConfig`,此时 CLI 会把配置参数传为 `nil`;也可以提供一个配置 loader:
对于接收配置的 entry,提供 `entryConfig.path` 后,fox-openapi 会优先在 entry
的配置类型所在包中自动使用包级 `Load(string) (*Config, error)` 函数:

```yaml
entryConfig:
path: config.yaml
```

只有当 loader 不是标准 `Load`,或不在配置类型所在包中时,才需要显式指定
`entryConfig.loader`:

```yaml
entryConfig:
loader: github.com/acme/myapp/internal/config.Load
loader: github.com/acme/myapp/internal/config.LoadForOpenAPI
path: config.yaml
```

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

## 路径解析

Expand Down
18 changes: 15 additions & 3 deletions cmd/fox-openapi/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ func runGenerate(cmd *cobra.Command, opts *commonOptions, args []string) error {
return exitError{code: cli.ExitWriteFailed, err: fmt.Errorf("write %s: %w", out, err)}
}
fmt.Printf("wrote %s (%s, %d bytes)\n", out, strings.ToUpper(cfg.Format), len(data))
fmt.Printf(" entry: %s%s\n", cfg.Entry, autoTag(cfg.EntryAutoDiscovered))
printInputSummary(cfg)
return nil
}

Expand Down Expand Up @@ -176,7 +176,7 @@ func newCheckCommand() *cobra.Command {
return exitError{code: cli.ExitWriteFailed, err: fmt.Errorf("check %s: %w", out, err)}
}
fmt.Printf("%s is up to date.\n", out)
fmt.Printf(" entry: %s%s\n", cfg.Entry, autoTag(cfg.EntryAutoDiscovered))
printInputSummary(cfg)
return nil
},
}
Expand Down Expand Up @@ -307,8 +307,9 @@ func bindCommonFlags(flags *pflag.FlagSet, opts *commonOptions) {
flags.Var(&opts.sources, "source", "source path")
flags.BoolVar(&o.IncludeTestFiles, "include-test-files", false, "include *_test.go")
flags.StringVar(&o.MetadataHook, "metadata-hook", "", "metadata hook")
flags.StringVar(&o.EntryConfigLoader, "entry-config-loader", "", "entry config loader")
flags.StringVar(&o.EntryConfigLoader, "entry-config-loader", "", "entry config loader (optional when config package has Load)")
flags.StringVar(&o.EntryConfigPath, "entry-config-path", "", "entry config path")
flags.StringVar(&o.RouteManifest, "route-manifest", "", "Fox route manifest path")
flags.StringVar(&o.Workdir, "workdir", ".", "user project root")
flags.BoolVar(&o.KeepDriver, "keep-driver", false, "keep generated driver")
flags.BoolVar(&o.Verbose, "verbose", false, "verbose output")
Expand All @@ -318,6 +319,7 @@ func bindCommonFlags(flags *pflag.FlagSet, opts *commonOptions) {
"metadata-hook",
"entry-config-loader",
"entry-config-path",
"route-manifest",
"keep-driver",
"verbose",
"format",
Expand Down Expand Up @@ -415,6 +417,8 @@ func markOverride(o *cli.Overrides, name string) {
o.EntryConfigLoaderSet = true
case "entry-config-path":
o.EntryConfigPathSet = true
case "route-manifest":
o.RouteManifestSet = true
case "workdir":
o.WorkdirSet = true
case "keep-driver":
Expand Down Expand Up @@ -443,6 +447,14 @@ func autoTag(autoDiscovered bool) string {
return ""
}

func printInputSummary(cfg cli.Config) {
if cfg.RouteManifest != "" {
fmt.Printf(" route manifest: %s\n", cfg.RouteManifest)
return
}
fmt.Printf(" entry: %s%s\n", cfg.Entry, autoTag(cfg.EntryAutoDiscovered))
}

type repeatedFlag struct {
values []string
set bool
Expand Down
11 changes: 10 additions & 1 deletion internal/cli/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type Config struct {
SecuritySchemes map[string]Scheme `yaml:"securitySchemes"`
MetadataHook string `yaml:"metadataHook"`
EntryConfig EntryConfig `yaml:"entryConfig"`
RouteManifest string `yaml:"routeManifest"`
Workdir string `yaml:"workdir"`
KeepDriver bool `yaml:"keepDriver"`
Verbose bool `yaml:"verbose"`
Expand Down Expand Up @@ -133,6 +134,8 @@ type Overrides struct {
EntryConfigLoaderSet bool
EntryConfigPath string
EntryConfigPathSet bool
RouteManifest string
RouteManifestSet bool
Workdir string
WorkdirSet bool
KeepDriver bool
Expand Down Expand Up @@ -214,7 +217,7 @@ func LoadConfig(overrides Overrides) (Config, error) {
if cfg.Format != FormatYAML && cfg.Format != FormatJSON {
return Config{}, fmt.Errorf("format must be yaml or json, got %q", cfg.Format)
}
if cfg.Entry == "" {
if cfg.Entry == "" && cfg.RouteManifest == "" {
// Discovery scope: explicit position arg > Sources > "./..." default.
// Comment extraction (Sources) stays module-wide so referenced types
// keep their field docs.
Expand Down Expand Up @@ -334,6 +337,9 @@ func mergeFromFile(cfg *Config, fileCfg Config, configDir string) {
if fileCfg.EntryConfig.Path != "" {
cfg.EntryConfig.Path = resolveRelative(configDir, fileCfg.EntryConfig.Path)
}
if fileCfg.RouteManifest != "" {
cfg.RouteManifest = resolveRelative(configDir, fileCfg.RouteManifest)
}
if fileCfg.Workdir != "" {
cfg.Workdir = resolveRelative(configDir, fileCfg.Workdir)
}
Expand Down Expand Up @@ -392,6 +398,9 @@ func applyOverrides(cfg *Config, o Overrides, cwd string) {
if o.EntryConfigPathSet {
cfg.EntryConfig.Path = resolveRelative(cwd, o.EntryConfigPath)
}
if o.RouteManifestSet {
cfg.RouteManifest = resolveRelative(cwd, o.RouteManifest)
}
if o.WorkdirSet {
cfg.Workdir = resolveRelative(cwd, o.Workdir)
}
Expand Down
19 changes: 19 additions & 0 deletions internal/cli/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,25 @@ func TestLoadConfigMissingFileUsesDefaults(t *testing.T) {
}
}

func TestLoadConfigRouteManifestDoesNotRequireEntry(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "fox-openapi.yaml")
if err := os.WriteFile(configPath, []byte(`
routeManifest: api/routes.manifest.json
out: api/openapi.yaml
`), 0o644); err != nil {
t.Fatal(err)
}

cfg, err := LoadConfig(Overrides{ConfigPath: configPath, ConfigExplicit: true})
if err != nil {
t.Fatal(err)
}
if cfg.Entry != "" || cfg.RouteManifest != filepath.Join(dir, "api/routes.manifest.json") {
t.Fatalf("unexpected config: entry=%q routeManifest=%q", cfg.Entry, cfg.RouteManifest)
}
}

func TestLoadConfigInvalidFormat(t *testing.T) {
_, err := LoadConfig(Overrides{
Entry: "example.com/app.NewEngine",
Expand Down
12 changes: 7 additions & 5 deletions internal/cli/discover.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,11 +142,13 @@ func entryFromFunc(importPath string, obj *types.Func) (Entry, bool) {
return Entry{}, false
}
return Entry{
ImportPath: importPath,
FuncName: obj.Name(),
TakesContext: shape.takesContext,
TakesConfig: shape.takesConfig,
ReturnsError: shape.returnsError,
ImportPath: importPath,
FuncName: obj.Name(),
TakesContext: shape.takesContext,
TakesConfig: shape.takesConfig,
ConfigImportPath: shape.configImportPath,
ConfigTypeName: shape.configTypeName,
ReturnsError: shape.returnsError,
}, true
}

Expand Down
Loading
Loading