diff --git a/README.md b/README.md index ac2d885..5761229 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 diff --git a/README.zh-CN.md b/README.zh-CN.md index 3b8d9c5..9eb0eff 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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` 必须指向一个导出函数,并符合以下签名之一: @@ -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`。 ## 路径解析 diff --git a/cmd/fox-openapi/main.go b/cmd/fox-openapi/main.go index 75870e3..c975ffb 100644 --- a/cmd/fox-openapi/main.go +++ b/cmd/fox-openapi/main.go @@ -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 } @@ -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 }, } @@ -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") @@ -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", @@ -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": @@ -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 diff --git a/internal/cli/config.go b/internal/cli/config.go index 0894c74..e15b2a1 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -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"` @@ -133,6 +134,8 @@ type Overrides struct { EntryConfigLoaderSet bool EntryConfigPath string EntryConfigPathSet bool + RouteManifest string + RouteManifestSet bool Workdir string WorkdirSet bool KeepDriver bool @@ -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. @@ -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) } @@ -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) } diff --git a/internal/cli/config_test.go b/internal/cli/config_test.go index f52bf38..62dc59a 100644 --- a/internal/cli/config_test.go +++ b/internal/cli/config_test.go @@ -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", diff --git a/internal/cli/discover.go b/internal/cli/discover.go index f25a68e..4953b5b 100644 --- a/internal/cli/discover.go +++ b/internal/cli/discover.go @@ -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 } diff --git a/internal/cli/manifest_types.go b/internal/cli/manifest_types.go new file mode 100644 index 0000000..11bb289 --- /dev/null +++ b/internal/cli/manifest_types.go @@ -0,0 +1,690 @@ +package cli + +import ( + "fmt" + "go/types" + "sort" + "strings" + + openapi "github.com/fox-gonic/openapi" + "golang.org/x/tools/go/packages" +) + +func enrichRouteManifestTypes(workdir string, manifest *openapi.RouteManifest, includeTestFiles bool) ([]string, error) { + resolver := &manifestTypeResolver{ + workdir: workdir, + includeTestFiles: includeTestFiles, + pkgs: map[string]*packages.Package{}, + pkgErrors: map[string]error{}, + } + var warnings []string + var routeSymbols []manifestRouteSymbol + importPaths := map[string]struct{}{} + for i := range manifest.Routes { + route := &manifest.Routes[i] + handlerName := route.HandlerSymbol() + if handlerName == "" || isRuntimeClosureHandlerSymbol(handlerName) { + continue + } + cleaned := openapi.CleanHandlerName(handlerName) + symbols := parseRuntimeHandlerSymbols(cleaned) + if len(symbols) == 0 { + warnings = append(warnings, fmt.Sprintf("WARN: route manifest %s %s: cannot resolve handler symbol %s", route.Method, route.Path, cleaned)) + continue + } + routeSymbols = append(routeSymbols, manifestRouteSymbol{ + route: route, + handlerName: cleaned, + symbols: symbols, + }) + for _, symbol := range symbols { + importPaths[symbol.importPath] = struct{}{} + } + } + if err := resolver.loadPackages(sortedKeys(importPaths)); err != nil { + return nil, err + } + for _, routeSymbol := range routeSymbols { + sig, err := resolver.handlerSignatureForSymbols(routeSymbol.handlerName, routeSymbol.symbols) + if err != nil { + warnings = append(warnings, fmt.Sprintf("WARN: route manifest %s %s: %v", routeSymbol.route.Method, routeSymbol.route.Path, err)) + continue + } + routeSymbol.route.InputTypes = manifestInputTypes(sig) + routeSymbol.route.ResultTypes = manifestResultTypes(sig) + } + return warnings, nil +} + +type manifestTypeResolver struct { + workdir string + includeTestFiles bool + pkgs map[string]*packages.Package + pkgErrors map[string]error +} + +type manifestRouteSymbol struct { + route *openapi.RouteManifestRoute + handlerName string + symbols []runtimeHandlerSymbol +} + +func (r *manifestTypeResolver) handlerSignature(handlerName string) (*types.Signature, error) { + handlerName = openapi.CleanHandlerName(handlerName) + symbols := parseRuntimeHandlerSymbols(handlerName) + if len(symbols) == 0 { + return nil, fmt.Errorf("cannot resolve handler symbol %s", handlerName) + } + if err := r.loadPackages(symbolImportPaths(symbols)); err != nil { + return nil, err + } + return r.handlerSignatureForSymbols(handlerName, symbols) +} + +func (r *manifestTypeResolver) handlerSignatureForSymbols(handlerName string, symbols []runtimeHandlerSymbol) (*types.Signature, error) { + var messages []string + for _, symbol := range symbols { + sig, err := r.handlerSignatureForSymbol(handlerName, symbol) + if err == nil { + return sig, nil + } + messages = append(messages, err.Error()) + } + return nil, fmt.Errorf("cannot resolve handler symbol %s: %s", handlerName, strings.Join(messages, "; ")) +} + +func (r *manifestTypeResolver) handlerSignatureForSymbol(handlerName string, symbol runtimeHandlerSymbol) (*types.Signature, error) { + if err := r.pkgErrors[symbol.importPath]; err != nil { + return nil, err + } + pkg, ok := r.pkgs[symbol.importPath] + if !ok || pkg == nil || pkg.Types == nil { + return nil, fmt.Errorf("package not found: %s", symbol.importPath) + } + var obj types.Object + if symbol.recvName == "" { + obj = pkg.Types.Scope().Lookup(symbol.funcName) + } else { + typeObj, ok := pkg.Types.Scope().Lookup(symbol.recvName).(*types.TypeName) + if !ok { + return nil, fmt.Errorf("receiver type not found: %s.%s", symbol.importPath, symbol.recvName) + } + named, ok := types.Unalias(typeObj.Type()).(*types.Named) + if !ok { + return nil, fmt.Errorf("%s.%s is not a named receiver type", symbol.importPath, symbol.recvName) + } + if len(symbol.recvTypeArgs) > 0 { + instantiated, err := r.instantiateNamed(pkg, named, symbol.recvTypeArgs) + if err != nil { + return nil, err + } + named = instantiated + } + obj, _, _ = types.LookupFieldOrMethod(types.NewPointer(named), true, pkg.Types, symbol.funcName) + } + fn, ok := obj.(*types.Func) + if !ok || fn == nil { + return nil, fmt.Errorf("handler function not found: %s", handlerName) + } + sig, ok := fn.Type().(*types.Signature) + if !ok { + return nil, fmt.Errorf("handler symbol is not a function: %s", handlerName) + } + if len(symbol.funcTypeArgs) > 0 { + return r.instantiateSignature(pkg, sig, symbol.funcTypeArgs) + } + return sig, nil +} + +func (r *manifestTypeResolver) instantiateNamed(pkg *packages.Package, named *types.Named, argNames []string) (*types.Named, error) { + args, err := r.resolveRuntimeTypeArgs(pkg, argNames) + if err != nil { + return nil, err + } + instantiated, err := types.Instantiate(types.NewContext(), named, args, true) + if err != nil { + return nil, fmt.Errorf("instantiate receiver %s[%s]: %w", named.Obj().Name(), strings.Join(argNames, ", "), err) + } + instNamed, ok := instantiated.(*types.Named) + if !ok { + return nil, fmt.Errorf("instantiated receiver is not a named type: %s", instantiated.String()) + } + return instNamed, nil +} + +func (r *manifestTypeResolver) instantiateSignature(pkg *packages.Package, sig *types.Signature, argNames []string) (*types.Signature, error) { + args, err := r.resolveRuntimeTypeArgs(pkg, argNames) + if err != nil { + return nil, err + } + instantiated, err := types.Instantiate(types.NewContext(), sig, args, true) + if err != nil { + return nil, fmt.Errorf("instantiate handler signature [%s]: %w", strings.Join(argNames, ", "), err) + } + instSig, ok := instantiated.(*types.Signature) + if !ok { + return nil, fmt.Errorf("instantiated handler is not a signature: %s", instantiated.String()) + } + return instSig, nil +} + +func (r *manifestTypeResolver) resolveRuntimeTypeArgs(pkg *packages.Package, argNames []string) ([]types.Type, error) { + args := make([]types.Type, 0, len(argNames)) + for _, argName := range argNames { + arg, err := r.resolveRuntimeTypeArg(pkg, argName) + if err != nil { + return nil, err + } + args = append(args, arg) + } + return args, nil +} + +func (r *manifestTypeResolver) resolveRuntimeTypeArg(pkg *packages.Package, argName string) (types.Type, error) { + argName = strings.TrimSpace(argName) + if strings.HasPrefix(argName, "*") { + elem, err := r.resolveRuntimeTypeArg(pkg, strings.TrimPrefix(argName, "*")) + if err != nil { + return nil, err + } + return types.NewPointer(elem), nil + } + if strings.HasPrefix(argName, "[]") { + elem, err := r.resolveRuntimeTypeArg(pkg, strings.TrimPrefix(argName, "[]")) + if err != nil { + return nil, err + } + return types.NewSlice(elem), nil + } + base, nestedArgs := splitRuntimeTypeName(argName) + typ, err := r.resolveRuntimeNamedType(pkg, base) + if err != nil { + return nil, err + } + if len(nestedArgs) == 0 { + return typ, nil + } + named, ok := typ.(*types.Named) + if !ok { + return nil, fmt.Errorf("type argument %s is not a named generic type", base) + } + return r.instantiateNamed(pkg, named, nestedArgs) +} + +func (r *manifestTypeResolver) resolveRuntimeNamedType(pkg *packages.Package, name string) (types.Type, error) { + if obj := types.Universe.Lookup(name); obj != nil { + if typeObj, ok := obj.(*types.TypeName); ok { + return typeObj.Type(), nil + } + } + if obj := pkg.Types.Scope().Lookup(name); obj != nil { + if typeObj, ok := obj.(*types.TypeName); ok { + return types.Unalias(typeObj.Type()), nil + } + } + idx := lastRuntimeSymbolDot(name) + if idx <= 0 || idx == len(name)-1 { + return nil, fmt.Errorf("type argument not found: %s", name) + } + importPath, typeName := name[:idx], name[idx+1:] + if err := r.loadPackages([]string{importPath}); err != nil { + return nil, err + } + if err := r.pkgErrors[importPath]; err != nil { + return nil, err + } + target, ok := r.pkgs[importPath] + if !ok || target == nil || target.Types == nil { + return nil, fmt.Errorf("package not found: %s", importPath) + } + typeObj, ok := target.Types.Scope().Lookup(typeName).(*types.TypeName) + if !ok { + return nil, fmt.Errorf("type argument not found: %s", name) + } + return types.Unalias(typeObj.Type()), nil +} + +func (r *manifestTypeResolver) loadPackages(importPaths []string) error { + if r.pkgs == nil { + r.pkgs = map[string]*packages.Package{} + } + if r.pkgErrors == nil { + r.pkgErrors = map[string]error{} + } + var missing []string + for _, importPath := range importPaths { + if importPath == "" { + continue + } + if _, ok := r.pkgs[importPath]; ok { + continue + } + if _, ok := r.pkgErrors[importPath]; ok { + continue + } + missing = append(missing, importPath) + } + if len(missing) == 0 { + return nil + } + cfg := &packages.Config{ + Dir: r.workdir, + Mode: packages.NeedName | packages.NeedTypes | packages.NeedImports | packages.NeedDeps, + Tests: r.includeTestFiles, + } + pkgs, err := packages.Load(cfg, missing...) + if err != nil { + return fmt.Errorf("load packages %s: %w", strings.Join(missing, ", "), err) + } + for _, pkg := range pkgs { + if pkg == nil { + continue + } + importPath := packageImportPath(pkg) + if importPath == "" { + continue + } + if len(pkg.Errors) > 0 { + r.pkgErrors[importPath] = fmt.Errorf("load package %s: %s", importPath, pkg.Errors[0]) + continue + } + if pkg.Types == nil { + r.pkgErrors[importPath] = fmt.Errorf("package not found: %s", importPath) + continue + } + r.pkgs[importPath] = pkg + } + for _, importPath := range missing { + if _, ok := r.pkgs[importPath]; ok { + continue + } + if _, ok := r.pkgErrors[importPath]; ok { + continue + } + r.pkgErrors[importPath] = fmt.Errorf("package not found: %s", importPath) + } + return nil +} + +func packageImportPath(pkg *packages.Package) string { + if pkg.PkgPath != "" { + return pkg.PkgPath + } + if pkg.Types != nil { + return pkg.Types.Path() + } + return pkg.ID +} + +type runtimeHandlerSymbol struct { + importPath string + recvName string + recvTypeArgs []string + funcName string + funcTypeArgs []string +} + +func parseRuntimeHandlerSymbol(handlerName string) (runtimeHandlerSymbol, bool) { + symbols := parseRuntimeHandlerSymbols(handlerName) + if len(symbols) == 0 { + return runtimeHandlerSymbol{}, false + } + return symbols[0], true +} + +func parseRuntimeHandlerSymbols(handlerName string) []runtimeHandlerSymbol { + if idx := strings.LastIndex(handlerName, ".("); idx >= 0 { + closeIdx := strings.Index(handlerName[idx+2:], ").") + if closeIdx < 0 { + return nil + } + recv := handlerName[idx+2 : idx+2+closeIdx] + method := handlerName[idx+2+closeIdx+2:] + if method == "" { + return nil + } + recvName, recvTypeArgs := splitRuntimeTypeName(strings.TrimPrefix(recv, "*")) + funcName, funcTypeArgs := splitRuntimeTypeName(strings.TrimSuffix(method, "-fm")) + return []runtimeHandlerSymbol{{ + importPath: handlerName[:idx], + recvName: recvName, + recvTypeArgs: recvTypeArgs, + funcName: funcName, + funcTypeArgs: funcTypeArgs, + }} + } + idx := lastRuntimeSymbolDot(handlerName) + if idx <= 0 || idx == len(handlerName)-1 { + return nil + } + funcName, funcTypeArgs := splitRuntimeTypeName(strings.TrimSuffix(handlerName[idx+1:], "-fm")) + var symbols []runtimeHandlerSymbol + if recvDot := previousRuntimeSymbolDot(handlerName, idx); recvDot > previousRuntimeSymbolSlash(handlerName, idx) { + recvName, recvTypeArgs := splitRuntimeTypeName(handlerName[recvDot+1 : idx]) + symbols = append(symbols, runtimeHandlerSymbol{ + importPath: handlerName[:recvDot], + recvName: recvName, + recvTypeArgs: recvTypeArgs, + funcName: funcName, + funcTypeArgs: funcTypeArgs, + }) + } + symbols = append(symbols, runtimeHandlerSymbol{ + importPath: handlerName[:idx], + funcName: funcName, + funcTypeArgs: funcTypeArgs, + }) + return symbols +} + +func lastRuntimeSymbolDot(handlerName string) int { + return previousRuntimeSymbolDot(handlerName, len(handlerName)) +} + +func previousRuntimeSymbolDot(handlerName string, before int) int { + return previousRuntimeSymbolByte(handlerName, before, '.') +} + +func previousRuntimeSymbolSlash(handlerName string, before int) int { + return previousRuntimeSymbolByte(handlerName, before, '/') +} + +func previousRuntimeSymbolByte(handlerName string, before int, want byte) int { + depth := 0 + for i := before - 1; i >= 0; i-- { + switch handlerName[i] { + case ']': + depth++ + case '[': + if depth > 0 { + depth-- + } + case want: + if depth == 0 { + return i + } + } + } + return -1 +} + +func symbolImportPaths(symbols []runtimeHandlerSymbol) []string { + result := make([]string, 0, len(symbols)) + for _, symbol := range symbols { + result = append(result, symbol.importPath) + } + return result +} + +func trimRuntimeTypeArgs(name string) string { + name, _ = splitRuntimeTypeName(name) + return name +} + +func splitRuntimeTypeName(name string) (string, []string) { + if open := strings.IndexByte(name, '['); open >= 0 { + if matchingRuntimeBracket(name, open) == len(name)-1 { + return name[:open], splitRuntimeTypeArgs(name[open+1 : len(name)-1]) + } + } + return name, nil +} + +func matchingRuntimeBracket(value string, open int) int { + depth := 0 + for i := open; i < len(value); i++ { + switch value[i] { + case '[': + depth++ + case ']': + depth-- + if depth == 0 { + return i + } + } + } + return -1 +} + +func splitRuntimeTypeArgs(value string) []string { + var args []string + start := 0 + depth := 0 + for i := 0; i < len(value); i++ { + switch value[i] { + case '[': + depth++ + case ']': + depth-- + case ',': + if depth == 0 { + args = append(args, strings.TrimSpace(value[start:i])) + start = i + 1 + } + } + } + args = append(args, strings.TrimSpace(value[start:])) + return args +} + +func isRuntimeClosureHandlerSymbol(name string) bool { + return openapi.CleanHandlerName(name) != strings.TrimSuffix(name, "-fm") +} + +func sortedKeys(values map[string]struct{}) []string { + result := make([]string, 0, len(values)) + for value := range values { + result = append(result, value) + } + sort.Strings(result) + return result +} + +func manifestInputTypes(sig *types.Signature) []openapi.RouteManifestType { + result := make([]openapi.RouteManifestType, 0, sig.Params().Len()) + for i := 0; i < sig.Params().Len(); i++ { + typ := sig.Params().At(i).Type() + if isFoxContextType(typ) { + continue + } + result = append(result, routeManifestTypeFromTypes(typ, map[types.Type]bool{})) + } + return result +} + +func manifestResultTypes(sig *types.Signature) []openapi.RouteManifestType { + result := make([]openapi.RouteManifestType, 0, sig.Results().Len()) + for i := 0; i < sig.Results().Len(); i++ { + result = append(result, routeManifestTypeFromTypes(sig.Results().At(i).Type(), map[types.Type]bool{})) + } + return result +} + +func routeManifestTypeFromTypes(typ types.Type, seen map[types.Type]bool) openapi.RouteManifestType { + if typ == nil { + return openapi.RouteManifestType{} + } + typ = types.Unalias(typ) + result := openapi.RouteManifestType{ + Kind: typeKind(typ), + String: typ.String(), + Name: typeName(typ), + } + if pkgPath := typePkgPath(typ); pkgPath != "" { + result.PkgPath = pkgPath + } + if seen[typ] || manifestOpaqueTypesType(typ) { + return result + } + seen[typ] = true + defer delete(seen, typ) + result.TypeArgs = typeArgs(typ, seen) + + container := typ + if _, ok := typ.(*types.Named); ok { + container = typ.Underlying() + } + switch t := container.(type) { + case *types.Pointer: + elem := routeManifestTypeFromTypes(t.Elem(), seen) + result.Elem = &elem + case *types.Slice: + elem := routeManifestTypeFromTypes(t.Elem(), seen) + result.Elem = &elem + case *types.Array: + elem := routeManifestTypeFromTypes(t.Elem(), seen) + result.Elem = &elem + case *types.Map: + key := routeManifestTypeFromTypes(t.Key(), seen) + elem := routeManifestTypeFromTypes(t.Elem(), seen) + result.Key = &key + result.Elem = &elem + case *types.Struct: + result.Fields = make([]openapi.RouteManifestField, 0, t.NumFields()) + for i := 0; i < t.NumFields(); i++ { + field := t.Field(i) + out := openapi.RouteManifestField{ + Name: field.Name(), + Tag: t.Tag(i), + Anonymous: field.Embedded(), + Type: routeManifestTypeFromTypes(field.Type(), seen), + } + if !field.Exported() && field.Pkg() != nil { + out.PkgPath = field.Pkg().Path() + } + result.Fields = append(result.Fields, out) + } + } + return result +} + +func typeKind(typ types.Type) string { + typ = types.Unalias(typ) + if _, ok := typ.(*types.Pointer); ok { + return "ptr" + } + switch t := typ.(type) { + case *types.Basic: + return basicKind(t) + case *types.Slice: + return "slice" + case *types.Array: + return "array" + case *types.Map: + return "map" + case *types.Interface: + return "interface" + } + switch derefTypes(typ).Underlying().(type) { + case *types.Struct: + return "struct" + case *types.Slice: + return "slice" + case *types.Array: + return "array" + case *types.Map: + return "map" + case *types.Basic: + return basicKind(derefTypes(typ).Underlying().(*types.Basic)) + case *types.Interface: + return "interface" + } + return "unknown" +} + +func basicKind(basic *types.Basic) string { + switch basic.Kind() { + case types.Bool: + return "bool" + case types.Int: + return "int" + case types.Int8: + return "int8" + case types.Int16: + return "int16" + case types.Int32: + return "int32" + case types.Int64: + return "int64" + case types.Uint: + return "uint" + case types.Uint8: + return "uint8" + case types.Uint16: + return "uint16" + case types.Uint32: + return "uint32" + case types.Uint64: + return "uint64" + case types.Float32: + return "float32" + case types.Float64: + return "float64" + case types.String: + return "string" + default: + return basic.Name() + } +} + +func typeName(typ types.Type) string { + typ = types.Unalias(typ) + if isErrorType(typ) { + return "error" + } + switch t := derefTypes(typ).(type) { + case *types.Basic: + return t.Name() + case *types.Named: + return t.Obj().Name() + } + return "" +} + +func typeArgs(typ types.Type, seen map[types.Type]bool) []openapi.RouteManifestType { + named, ok := derefTypes(types.Unalias(typ)).(*types.Named) + if !ok || named.TypeArgs() == nil || named.TypeArgs().Len() == 0 { + return nil + } + args := make([]openapi.RouteManifestType, 0, named.TypeArgs().Len()) + for i := 0; i < named.TypeArgs().Len(); i++ { + args = append(args, routeManifestTypeFromTypes(named.TypeArgs().At(i), seen)) + } + return args +} + +func typePkgPath(typ types.Type) string { + typ = types.Unalias(typ) + if named, ok := derefTypes(typ).(*types.Named); ok && named.Obj() != nil && named.Obj().Pkg() != nil { + return named.Obj().Pkg().Path() + } + return "" +} + +func derefTypes(typ types.Type) types.Type { + for { + ptr, ok := typ.(*types.Pointer) + if !ok { + return typ + } + typ = ptr.Elem() + } +} + +func manifestOpaqueTypesType(typ types.Type) bool { + typ = types.Unalias(typ) + return typePkgPath(typ) == "time" +} + +func isFoxContextType(typ types.Type) bool { + typ = types.Unalias(typ) + ptr, ok := typ.(*types.Pointer) + if ok { + typ = ptr.Elem() + } + named, ok := typ.(*types.Named) + if !ok || named.Obj() == nil || named.Obj().Name() != "Context" || named.Obj().Pkg() == nil { + return false + } + return named.Obj().Pkg().Path() == "github.com/fox-gonic/fox" +} diff --git a/internal/cli/manifest_types_test.go b/internal/cli/manifest_types_test.go new file mode 100644 index 0000000..9b53ada --- /dev/null +++ b/internal/cli/manifest_types_test.go @@ -0,0 +1,232 @@ +package cli + +import ( + "path/filepath" + "testing" + + "go/types" + + openapi "github.com/fox-gonic/openapi" + "golang.org/x/tools/go/packages" +) + +func TestRouteManifestTypeFromTypesUsesStructuredTypeArgs(t *testing.T) { + dir := writeUserModule(t) + resolver := manifestTypeResolver{ + workdir: dir, + pkgs: map[string]*packages.Package{}, + } + sig, err := resolver.handlerSignature("example.com/app/internal/server.GetGenericUser") + if err != nil { + t.Fatal(err) + } + + results := manifestResultTypes(sig) + if len(results) != 2 { + t.Fatalf("results = %#v", results) + } + response := results[0] + if response.Name != "GenericResponse" { + t.Fatalf("response name = %q", response.Name) + } + if len(response.TypeArgs) != 1 { + t.Fatalf("type args = %#v", response.TypeArgs) + } + if response.TypeArgs[0].Name != "User" || response.TypeArgs[0].PkgPath != "example.com/app/internal/server" { + t.Fatalf("type arg = %#v", response.TypeArgs[0]) + } +} + +func TestRouteManifestTypeFromTypesPopulatesString(t *testing.T) { + field := types.NewVar(0, nil, "Items", types.NewSlice(types.Typ[types.String])) + typ := types.NewStruct([]*types.Var{field}, []string{`json:"items"`}) + + result := routeManifestTypeFromTypes(typ, map[types.Type]bool{}) + + if result.String == "" { + t.Fatalf("string is empty: %#v", result) + } + if len(result.Fields) != 1 { + t.Fatalf("fields = %#v", result.Fields) + } + items := result.Fields[0].Type + if items.String != "[]string" { + t.Fatalf("slice string = %q, want []string", items.String) + } + if items.Elem == nil || items.Elem.String != "string" { + t.Fatalf("elem = %#v, want string", items.Elem) + } +} + +func TestEnrichRouteManifestTypesDoesNotSkipFuncInImportPath(t *testing.T) { + dir := writeUserModule(t) + manifest := openapi.RouteManifest{ + Version: openapi.RouteManifestVersion, + Routes: []openapi.RouteManifestRoute{{ + Method: "GET", + Path: "/users/:id", + Handler: "example.com/app/internal/server.GetUser", + }}, + } + + warnings, err := enrichRouteManifestTypes(dir, &manifest, false) + if err != nil { + t.Fatal(err) + } + if len(warnings) != 0 { + t.Fatalf("warnings = %#v", warnings) + } + if len(manifest.Routes[0].ResultTypes) == 0 { + t.Fatalf("route was not enriched: %#v", manifest.Routes[0]) + } +} + +func TestEnrichRouteManifestTypesIncludesTestFiles(t *testing.T) { + dir := writeUserModule(t) + writeFile(t, filepath.Join(dir, "internal/server/server_test.go"), `package server + +import "github.com/fox-gonic/fox" + +func GetTestUser(ctx *fox.Context) (User, error) { + return User{ID: "1", Name: "Ada"}, nil +} +`) + manifest := openapi.RouteManifest{ + Version: openapi.RouteManifestVersion, + Routes: []openapi.RouteManifestRoute{{ + Method: "GET", + Path: "/test-users/:id", + Handler: "example.com/app/internal/server.GetTestUser", + }}, + } + + warnings, err := enrichRouteManifestTypes(dir, &manifest, true) + if err != nil { + t.Fatal(err) + } + if len(warnings) != 0 { + t.Fatalf("warnings = %#v", warnings) + } + if len(manifest.Routes[0].ResultTypes) == 0 || manifest.Routes[0].ResultTypes[0].Name != "User" { + t.Fatalf("route was not enriched from test file: %#v", manifest.Routes[0]) + } +} + +func TestCleanRuntimeHandlerNameOnlyTreatsFuncNumberSuffixAsClosure(t *testing.T) { + for _, value := range []string{ + "example.com/my.func/pkg.Handler", + "example.com/app.Function", + "example.com/app.(*Handler).Function-fm", + } { + if isRuntimeClosureHandlerSymbol(value) { + t.Fatalf("%q detected as closure", value) + } + } + if !isRuntimeClosureHandlerSymbol("example.com/app.NewEngine.func1") { + t.Fatal("closure was not detected") + } +} + +func TestParseRuntimeHandlerSymbolHandlesGenericRuntimeNames(t *testing.T) { + symbol, ok := parseRuntimeHandlerSymbol("example.com/app/internal/server.GetUser[example.com/app/internal/model.User]") + if !ok { + t.Fatal("symbol was not parsed") + } + if symbol.importPath != "example.com/app/internal/server" || symbol.funcName != "GetUser" || symbol.recvName != "" { + t.Fatalf("symbol = %#v", symbol) + } + + symbol, ok = parseRuntimeHandlerSymbol("example.com/app/internal/server.(*Handler[example.com/app/internal/model.User]).GetUser-fm") + if !ok { + t.Fatal("method symbol was not parsed") + } + if symbol.importPath != "example.com/app/internal/server" || symbol.recvName != "Handler" || symbol.funcName != "GetUser" { + t.Fatalf("method symbol = %#v", symbol) + } +} + +func TestParseRuntimeHandlerSymbolsIncludesValueReceiverCandidate(t *testing.T) { + symbols := parseRuntimeHandlerSymbols("example.com/app/internal/server.Handler.GetUser") + if len(symbols) != 2 { + t.Fatalf("symbols = %#v", symbols) + } + if symbols[0].importPath != "example.com/app/internal/server" || symbols[0].recvName != "Handler" || symbols[0].funcName != "GetUser" { + t.Fatalf("receiver symbol = %#v", symbols[0]) + } + if symbols[1].importPath != "example.com/app/internal/server.Handler" || symbols[1].recvName != "" || symbols[1].funcName != "GetUser" { + t.Fatalf("function fallback = %#v", symbols[1]) + } +} + +func TestHandlerSignatureResolvesAliasReceivers(t *testing.T) { + dir := writeUserModule(t) + resolver := manifestTypeResolver{ + workdir: dir, + pkgs: map[string]*packages.Package{}, + } + + sig, err := resolver.handlerSignature("example.com/app/internal/server.(*AliasHandler).AliasUser-fm") + if err != nil { + t.Fatal(err) + } + results := manifestResultTypes(sig) + if len(results) == 0 || results[0].Name != "User" { + t.Fatalf("results = %#v", results) + } +} + +func TestHandlerSignatureResolvesValueReceivers(t *testing.T) { + dir := writeUserModule(t) + resolver := manifestTypeResolver{workdir: dir} + + sig, err := resolver.handlerSignature("example.com/app/internal/server.Handler.ValueUser") + if err != nil { + t.Fatal(err) + } + results := manifestResultTypes(sig) + if len(results) == 0 || results[0].Name != "User" { + t.Fatalf("results = %#v", results) + } +} + +func TestHandlerSignatureInstantiatesGenericFunctions(t *testing.T) { + dir := writeUserModule(t) + resolver := manifestTypeResolver{workdir: dir} + + sig, err := resolver.handlerSignature("example.com/app/internal/server.GetGenericRuntimeUser[example.com/app/internal/server.User]") + if err != nil { + t.Fatal(err) + } + results := manifestResultTypes(sig) + if len(results) == 0 { + t.Fatalf("results = %#v", results) + } + if results[0].Name != "GenericResponse" || len(results[0].TypeArgs) != 1 || results[0].TypeArgs[0].Name != "User" { + t.Fatalf("generic result = %#v", results[0]) + } + data := results[0].Fields[0] + if data.Type.Kind == "unknown" || data.Type.Name != "User" { + t.Fatalf("generic field was not instantiated: %#v", data.Type) + } +} + +func TestHandlerSignatureInstantiatesGenericReceivers(t *testing.T) { + dir := writeUserModule(t) + resolver := manifestTypeResolver{workdir: dir} + + sig, err := resolver.handlerSignature("example.com/app/internal/server.GenericHandler[example.com/app/internal/server.User].GenericUser") + if err != nil { + t.Fatal(err) + } + results := manifestResultTypes(sig) + if len(results) == 0 { + t.Fatalf("results = %#v", results) + } + if results[0].Name != "GenericResponse" || len(results[0].TypeArgs) != 1 || results[0].TypeArgs[0].Name != "User" { + t.Fatalf("generic receiver result = %#v", results[0]) + } + data := results[0].Fields[0] + if data.Type.Kind == "unknown" || data.Type.Name != "User" { + t.Fatalf("generic receiver field was not instantiated: %#v", data.Type) + } +} diff --git a/internal/cli/pipeline.go b/internal/cli/pipeline.go index 2618409..0b79729 100644 --- a/internal/cli/pipeline.go +++ b/internal/cli/pipeline.go @@ -1,8 +1,18 @@ package cli -import "path/filepath" +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + openapi "github.com/fox-gonic/openapi" +) func RunPipeline(cfg Config) ([]byte, []string, error) { + if cfg.RouteManifest != "" { + return runManifestPipeline(cfg) + } var entry Entry if cfg.ResolvedEntry != nil { // DiscoverEntry already loaded packages and validated the signature @@ -22,6 +32,12 @@ func RunPipeline(cfg Config) ([]byte, []string, error) { return nil, nil, err } loader = &resolved + } else if entry.TakesConfig && cfg.EntryConfig.Path != "" { + resolved, err := ResolveConfigLoaderFromEntry(cfg.Workdir, entry, cfg.EntryConfig.Path) + if err != nil { + return nil, nil, err + } + loader = &resolved } var hook *Hook if cfg.MetadataHook != "" { @@ -43,6 +59,134 @@ func RunPipeline(cfg Config) ([]byte, []string, error) { return data, warningLines(stderr), nil } +func runManifestPipeline(cfg Config) ([]byte, []string, error) { + data, err := os.ReadFile(cfg.RouteManifest) + if err != nil { + return nil, nil, fmt.Errorf("read route manifest %s: %w", cfg.RouteManifest, err) + } + var manifest openapi.RouteManifest + if err := json.Unmarshal(data, &manifest); err != nil { + return nil, nil, fmt.Errorf("parse route manifest %s: %w", cfg.RouteManifest, err) + } + if manifest.Version != openapi.RouteManifestVersion { + return nil, nil, fmt.Errorf("unsupported route manifest version %q, want %q", manifest.Version, openapi.RouteManifestVersion) + } + warnings, err := enrichRouteManifestTypes(cfg.Workdir, &manifest, cfg.IncludeTestFiles) + if err != nil { + return nil, nil, err + } + opts, err := manifestOptions(cfg) + if err != nil { + return nil, nil, err + } + g := openapi.NewFromRouteManifest(manifest, opts...) + spec := g.Spec() + openapi.ApplySpecMetadata(spec, specMetadata(cfg)) + var out []byte + if cfg.Format == FormatJSON { + out, err = openapi.MarshalSpecJSON(spec) + } else { + out, err = openapi.MarshalSpecYAML(spec) + } + if err != nil { + return nil, nil, fmt.Errorf("generate spec: %w", err) + } + return out, append(warnings, g.Warnings()...), nil +} + +func manifestOptions(cfg Config) ([]openapi.Option, error) { + opts := []openapi.Option{ + openapi.Info(defaultString(cfg.Info.Title, "Fox API"), defaultString(cfg.Info.Version, "0.0.0")), + } + for _, server := range cfg.Servers { + if server.URL != "" { + opts = append(opts, openapi.Server(server.URL)) + } + } + sources, err := absoluteSources(cfg.Workdir, cfg.Sources) + if err != nil { + return nil, err + } + if len(sources) > 0 { + sourceOpts := []openapi.SourceOption{} + if cfg.IncludeTestFiles { + sourceOpts = append(sourceOpts, openapi.IncludeTestFiles()) + } + opts = append(opts, openapi.Source(sources, sourceOpts...)) + } + for _, name := range sortedSchemeNames(cfg.SecuritySchemes) { + opts = append(opts, openapi.SecuritySchemeFromConfig(name, securitySchemeConfig(cfg.SecuritySchemes[name]))) + } + return opts, nil +} + +func specMetadata(cfg Config) openapi.SpecMetadata { + serverDescriptions := make([]string, len(cfg.Servers)) + for i, server := range cfg.Servers { + serverDescriptions[i] = server.Description + } + tags := make([]openapi.SpecTag, 0, len(cfg.Tags)) + for _, tag := range cfg.Tags { + out := openapi.SpecTag{Name: tag.Name, Description: tag.Description} + if tag.ExternalDocs != nil { + out.ExternalDocs = &openapi.SpecExternalDocs{ + Description: tag.ExternalDocs.Description, + URL: tag.ExternalDocs.URL, + } + } + tags = append(tags, out) + } + return openapi.SpecMetadata{ + InfoDescription: cfg.Info.Description, + ServerDescriptions: serverDescriptions, + Tags: tags, + } +} + +func securitySchemeConfig(s Scheme) openapi.SecuritySchemeConfig { + return openapi.SecuritySchemeConfig{ + Type: s.Type, + Description: s.Description, + Name: s.Name, + In: s.In, + Scheme: s.Scheme, + BearerFormat: s.BearerFormat, + OpenIDConnectURL: s.OpenIDConnectURL, + Flows: oauthFlowsConfig(s.Flows), + } +} + +func oauthFlowsConfig(flows *OAuthFlows) *openapi.OAuthFlowsConfig { + if flows == nil { + return nil + } + return &openapi.OAuthFlowsConfig{ + Implicit: oauthFlowConfig(flows.Implicit), + Password: oauthFlowConfig(flows.Password), + ClientCredentials: oauthFlowConfig(flows.ClientCredentials), + AuthorizationCode: oauthFlowConfig(flows.AuthorizationCode), + } +} + +func oauthFlowConfig(flow *OAuthFlow) *openapi.OAuthFlowConfig { + if flow == nil { + return nil + } + return &openapi.OAuthFlowConfig{ + AuthorizationURL: flow.AuthorizationURL, + TokenURL: flow.TokenURL, + RefreshURL: flow.RefreshURL, + Scopes: flow.Scopes, + } +} + +func defaultString(value, fallback string) string { + if value == "" { + return fallback + } + return value +} + func ResolveOutputPath(cfg Config) string { // cfg.Out is fully resolved during LoadConfig — CLI flags are joined // against CWD and YAML values are joined against the config file's diff --git a/internal/cli/pipeline_test.go b/internal/cli/pipeline_test.go index 1e8bd0e..5e9bab3 100644 --- a/internal/cli/pipeline_test.go +++ b/internal/cli/pipeline_test.go @@ -51,6 +51,70 @@ func TestRunPipelineGeneratesSpecFromUserModule(t *testing.T) { } } +func TestRunPipelineGeneratesSpecFromRouteManifest(t *testing.T) { + dir := writeUserModule(t) + manifestPath := filepath.Join(dir, "routes.manifest.json") + if err := os.WriteFile(manifestPath, []byte(`{ + "version": "fox.route-manifest/v1", + "routes": [ + { + "method": "GET", + "path": "/users/:id", + "handler": "example.com/app/internal/server.GetUser" + } + ] +}`), 0o644); err != nil { + t.Fatal(err) + } + + cfg := Config{ + RouteManifest: manifestPath, + Out: "api/openapi.yaml", + Format: "yaml", + Sources: []string{}, + Info: InfoConfig{Title: "Manifest API", Version: "1.0.0"}, + Workdir: dir, + } + data, warnings, err := RunPipeline(cfg) + if err != nil { + t.Fatal(err) + } + if len(warnings) != 0 { + t.Fatalf("warnings = %#v", warnings) + } + for _, want := range []string{ + "title: Manifest API", + "/users/{id}:", + "operationId: example_com_app_internal_server_GetUser", + "name: id", + "server_User", + "default:", + } { + if !bytes.Contains(data, []byte(want)) { + t.Fatalf("generated spec missing %q:\n%s", want, data) + } + } +} + +func TestRunPipelineRejectsUnsupportedRouteManifestVersion(t *testing.T) { + dir := t.TempDir() + manifestPath := filepath.Join(dir, "routes.manifest.json") + if err := os.WriteFile(manifestPath, []byte(`{"version":"fox.route-manifest/v0","routes":[]}`), 0o644); err != nil { + t.Fatal(err) + } + + _, _, err := RunPipeline(Config{ + RouteManifest: manifestPath, + Out: "api/openapi.yaml", + Format: "yaml", + Info: InfoConfig{Title: "Manifest API", Version: "1.0.0"}, + Workdir: dir, + }) + if err == nil || !strings.Contains(err.Error(), "unsupported route manifest version") { + t.Fatalf("RunPipeline error = %v, want unsupported route manifest version", err) + } +} + func TestRunPipelineDoesNotRequireOpenAPIModuleInUserGoMod(t *testing.T) { dir := writeUserModuleWithoutOpenAPIRequire(t) before, err := os.ReadFile(filepath.Join(dir, "go.mod")) @@ -105,6 +169,28 @@ func TestRunPipelineSupportsContextAndConfigEntry(t *testing.T) { } } +func TestRunPipelineAutoDiscoversConfigLoaderFromEntryConfigPath(t *testing.T) { + dir := writeUserModule(t) + cfg := Config{ + Entry: "example.com/app/internal/server.NewEngineWithRequiredConfig", + Out: "api/openapi.yaml", + Format: "yaml", + Sources: []string{"./internal/server"}, + Info: InfoConfig{Title: "Example API", Version: "1.0.0"}, + EntryConfig: EntryConfig{ + Path: "config.yaml", + }, + Workdir: dir, + } + data, _, err := RunPipeline(cfg) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(data, []byte("/configured/loaded:")) { + t.Fatalf("generated spec missing route registered by configured engine:\n%s", data) + } +} + func TestRunPipelineSupportsContextAndNilConfigEntry(t *testing.T) { dir := writeUserModule(t) cfg := Config{ diff --git a/internal/cli/resolve.go b/internal/cli/resolve.go index 72447cf..f602a4b 100644 --- a/internal/cli/resolve.go +++ b/internal/cli/resolve.go @@ -10,11 +10,13 @@ import ( ) type Entry struct { - ImportPath string - FuncName string - ReturnsError bool - TakesContext bool - TakesConfig bool + ImportPath string + FuncName string + ReturnsError bool + TakesContext bool + TakesConfig bool + ConfigImportPath string + ConfigTypeName string } type Hook struct { @@ -45,11 +47,13 @@ func ResolveEntry(workdir, value string) (Entry, error) { return Entry{}, entrySignatureError(value) } return Entry{ - ImportPath: importPath, - FuncName: funcName, - TakesContext: shape.takesContext, - TakesConfig: shape.takesConfig, - ReturnsError: shape.returnsError, + ImportPath: importPath, + FuncName: funcName, + TakesContext: shape.takesContext, + TakesConfig: shape.takesConfig, + ConfigImportPath: shape.configImportPath, + ConfigTypeName: shape.configTypeName, + ReturnsError: shape.returnsError, }, nil } @@ -58,9 +62,11 @@ func ResolveEntry(workdir, value string) (Entry, error) { // entryFromFunc (auto-discovery) share matchEntrySignature so the list // of supported shapes lives in a single place. type entrySignatureShape struct { - takesContext bool - takesConfig bool - returnsError bool + takesContext bool + takesConfig bool + configImportPath string + configTypeName string + returnsError bool } func matchEntrySignature(sig *types.Signature) (entrySignatureShape, bool) { @@ -77,6 +83,12 @@ func matchEntrySignature(sig *types.Signature) (entrySignatureShape, bool) { } if paramCount == 2 { shape.takesConfig = true + importPath, typeName, ok := configParamType(sig.Params().At(1).Type()) + if !ok { + return shape, false + } + shape.configImportPath = importPath + shape.configTypeName = typeName } results := sig.Results() switch results.Len() { @@ -96,6 +108,10 @@ func matchEntrySignature(sig *types.Signature) (entrySignatureShape, bool) { } func ResolveConfigLoader(workdir, value, path string) (ConfigLoader, error) { + return resolveConfigLoader(workdir, value, path, "", "") +} + +func resolveConfigLoader(workdir, value, path, wantImportPath, wantTypeName string) (ConfigLoader, error) { importPath, funcName, err := splitSymbol(value) if err != nil { return ConfigLoader{}, err @@ -112,12 +128,29 @@ func ResolveConfigLoader(workdir, value, path string) (ConfigLoader, error) { sig.Results().Len() != 2 || !isErrorType(sig.Results().At(1).Type()) { return ConfigLoader{}, fmt.Errorf("entry config loader signature mismatch for %s: expected func(string) (*Config, error)", value) } + if wantImportPath != "" || wantTypeName != "" { + gotImportPath, gotTypeName, ok := configParamType(sig.Results().At(0).Type()) + if !ok || gotImportPath != wantImportPath || gotTypeName != wantTypeName { + return ConfigLoader{}, fmt.Errorf("entry config loader signature mismatch for %s: expected func(string) (*%s.%s, error)", value, wantImportPath, wantTypeName) + } + } if path != "" && !filepath.IsAbs(path) { path = filepath.Join(workdir, path) } return ConfigLoader{ImportPath: importPath, FuncName: funcName, Path: path}, nil } +func ResolveConfigLoaderFromEntry(workdir string, entry Entry, path string) (ConfigLoader, error) { + if !entry.TakesConfig || entry.ConfigImportPath == "" || entry.ConfigTypeName == "" { + return ConfigLoader{}, fmt.Errorf("entry %s.%s does not expose a concrete config type", entry.ImportPath, entry.FuncName) + } + loader, err := resolveConfigLoader(workdir, entry.ConfigImportPath+".Load", path, entry.ConfigImportPath, entry.ConfigTypeName) + if err != nil { + return ConfigLoader{}, err + } + return loader, nil +} + func ResolveHook(workdir, value string) (Hook, error) { importPath, funcName, err := splitSymbol(value) if err != nil { @@ -190,6 +223,18 @@ func isContextType(typ types.Type) bool { return pkg != nil && pkg.Path() == "context" } +func configParamType(typ types.Type) (string, string, bool) { + ptr, ok := typ.(*types.Pointer) + if !ok { + return "", "", false + } + named, ok := ptr.Elem().(*types.Named) + if !ok || named.Obj() == nil || named.Obj().Pkg() == nil { + return "", "", false + } + return named.Obj().Pkg().Path(), named.Obj().Name(), true +} + func isStringType(typ types.Type) bool { basic, ok := typ.(*types.Basic) return ok && basic.Kind() == types.String diff --git a/internal/cli/resolve_test.go b/internal/cli/resolve_test.go index ded6c74..dd325a1 100644 --- a/internal/cli/resolve_test.go +++ b/internal/cli/resolve_test.go @@ -36,6 +36,9 @@ func TestResolveEntryValidSignatures(t *testing.T) { if !entry.TakesContext || !entry.TakesConfig { t.Fatalf("expected context config entry: %+v", entry) } + if entry.ConfigImportPath != "example.com/app/internal/config" || entry.ConfigTypeName != "Config" { + t.Fatalf("expected config type metadata: %+v", entry) + } entry, err = ResolveEntry(dir, "example.com/app/internal/server.NewEngineWithConfigNoError") if err != nil { t.Fatal(err) @@ -65,6 +68,22 @@ func TestResolveConfigLoader(t *testing.T) { } } +func TestResolveConfigLoaderFromEntry(t *testing.T) { + dir := writeUserModule(t) + entry, err := ResolveEntry(dir, "example.com/app/internal/server.NewEngineWithConfig") + if err != nil { + t.Fatal(err) + } + loader, err := ResolveConfigLoaderFromEntry(dir, entry, "config.yaml") + if err != nil { + t.Fatal(err) + } + wantPath := dir + string(filepath.Separator) + "config.yaml" + if loader.ImportPath != "example.com/app/internal/config" || loader.FuncName != "Load" || loader.Path != wantPath { + t.Fatalf("unexpected loader: %+v", loader) + } +} + func TestResolveHook(t *testing.T) { dir := writeUserModule(t) hook, err := ResolveHook(dir, "example.com/app/internal/server.ConfigureOpenAPI") diff --git a/internal/cli/testhelper_test.go b/internal/cli/testhelper_test.go index 7920b42..ba89fd3 100644 --- a/internal/cli/testhelper_test.go +++ b/internal/cli/testhelper_test.go @@ -75,6 +75,7 @@ replace github.com/fox-gonic/openapi => `+filepath.ToSlash(openapiRoot)+` import ( "context" "errors" + "fmt" "reflect" "github.com/fox-gonic/fox" @@ -92,11 +93,41 @@ type User struct { Name string `+"`json:\"name\"`"+` } +type GenericResponse[T any] struct { + Data T `+"`json:\"data\"`"+` +} + +type Handler struct{} + +type GenericHandler[T any] struct{} + +type AliasHandler = Handler + // GetUser fetches a user by id. func GetUser(ctx *fox.Context, req GetUserRequest) (User, error) { return User{ID: req.ID, Name: "Ada"}, nil } +func GetGenericUser(ctx *fox.Context) (GenericResponse[User], error) { + return GenericResponse[User]{Data: User{ID: "1", Name: "Ada"}}, nil +} + +func GetGenericRuntimeUser[T any](ctx *fox.Context) (GenericResponse[T], error) { + return GenericResponse[T]{}, nil +} + +func (h *Handler) AliasUser(ctx *fox.Context) (User, error) { + return User{ID: "1", Name: "Ada"}, nil +} + +func (h Handler) ValueUser(ctx *fox.Context) (User, error) { + return User{ID: "1", Name: "Ada"}, nil +} + +func (h GenericHandler[T]) GenericUser(ctx *fox.Context) (GenericResponse[T], error) { + return GenericResponse[T]{}, nil +} + func NewEngine() *fox.Engine { e := fox.New() e.GET("/users/:id", GetUser) @@ -115,6 +146,15 @@ func NewEngineWithConfig(ctx context.Context, cfg *config.Config) (*fox.Engine, return NewEngine(), nil } +func NewEngineWithRequiredConfig(ctx context.Context, cfg *config.Config) (*fox.Engine, error) { + if cfg == nil { + return nil, errors.New("config is required") + } + e := NewEngine() + e.GET(fmt.Sprintf("/configured/%s", cfg.Name), GetUser) + return e, nil +} + func NewEngineWithConfigNoError(ctx context.Context, cfg *config.Config) *fox.Engine { return NewEngine() } @@ -141,7 +181,7 @@ type Config struct { } func Load(path string) (*Config, error) { - return &Config{Name: path}, nil + return &Config{Name: "loaded"}, nil } `) cmd := exec.Command("go", "mod", "tidy") diff --git a/openapi.go b/openapi.go index fb87fe6..e081562 100644 --- a/openapi.go +++ b/openapi.go @@ -27,17 +27,20 @@ type Option func(*Generator) // next Spec()/JSON()/YAML() call. Schemas are cached across regenerations so // repeated calls only re-walk the route table. type Generator struct { - engine *fox.Engine - spec *openapi3.T - schemaNames map[reflect.Type]string - schemaByName map[string]reflect.Type - warnings []string - docs *commentDocs - operations map[operationKey]operationDoc - groups []groupDoc - formatters map[reflect.Type]*openapi3.Schema - errorSchema reflect.Type - generated bool + engine *fox.Engine + manifest *RouteManifest + spec *openapi3.T + schemaNames map[reflect.Type]string + schemaByName map[string]reflect.Type + manifestSchemaNames map[string]string + manifestSchemaByName map[string]string + warnings []string + docs *commentDocs + operations map[operationKey]operationDoc + groups []groupDoc + formatters map[reflect.Type]*openapi3.Schema + errorSchema reflect.Type + generated bool } // Info sets the OpenAPI info title and version. @@ -61,11 +64,13 @@ func New(engine *fox.Engine, opts ...Option) *Generator { components.Schemas = openapi3.Schemas{} g := &Generator{ - engine: engine, - schemaNames: make(map[reflect.Type]string), - schemaByName: make(map[string]reflect.Type), - operations: make(map[operationKey]operationDoc), - formatters: make(map[reflect.Type]*openapi3.Schema), + engine: engine, + schemaNames: make(map[reflect.Type]string), + schemaByName: make(map[string]reflect.Type), + manifestSchemaNames: make(map[string]string), + manifestSchemaByName: make(map[string]string), + operations: make(map[operationKey]operationDoc), + formatters: make(map[reflect.Type]*openapi3.Schema), spec: &openapi3.T{ OpenAPI: "3.0.3", Info: &openapi3.Info{Title: "Fox API", Version: "0.0.0"}, @@ -81,6 +86,14 @@ func New(engine *fox.Engine, opts ...Option) *Generator { return g } +// NewFromRouteManifest creates a Generator from a Fox route manifest instead +// of a live Engine. +func NewFromRouteManifest(manifest RouteManifest, opts ...Option) *Generator { + g := New(nil, opts...) + g.manifest = &manifest + return g +} + // Spec returns the generated OpenAPI model. The first call walks the engine's // route table; subsequent calls also re-walk so freshly registered routes are // reflected. @@ -97,6 +110,8 @@ func (g *Generator) Regenerate() { g.warnings = nil g.schemaNames = make(map[reflect.Type]string) g.schemaByName = make(map[string]reflect.Type) + g.manifestSchemaNames = make(map[string]string) + g.manifestSchemaByName = make(map[string]string) g.spec.Paths = openapi3.NewPaths() g.spec.Components.Schemas = openapi3.Schemas{} g.spec.Components.Responses = openapi3.ResponseBodies{} @@ -145,11 +160,456 @@ func (g *Generator) WriteYAML(w io.Writer) error { } func (g *Generator) generate() { + if g.manifest != nil { + for _, route := range g.manifest.Routes { + g.generateManifestRoute(route) + } + return + } for _, route := range g.engine.HandlerRoutes() { g.generateRoute(route) } } +func (g *Generator) generateManifestRoute(route RouteManifestRoute) { + op := openapi3.NewOperation() + if route.HandlerSymbol() != "" { + op.OperationID = sanitizeName(cleanHandlerName(route.HandlerSymbol())) + } else { + 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 + } + } + + if input, ok := manifestRequestBody(route); ok { + g.addManifestInput(op, route, input) + } + g.addMissingPathParams(op, route.Path) + status := http.StatusOK + statusInferred := false + if g.docs != nil { + if inferred, ok := g.docs.returnStatus(route.HandlerSymbol()); ok { + status = inferred + statusInferred = true + } + } + if body, ok := manifestSuccessBody(route); ok { + if statusInferred { + if inferred, ok := g.sourceInferredManifestSuccessBody(body); ok { + body = inferred + } + } + op.Responses.Set(strconv.Itoa(status), &openapi3.ResponseRef{Value: g.manifestSuccessResponse(status, body)}) + } else { + op.Responses.Set(strconv.Itoa(status), &openapi3.ResponseRef{Value: openapi3.NewResponse(). + WithDescription(http.StatusText(status))}) + } + if manifestRouteReturnsError(route) { + op.Responses.Set("default", &openapi3.ResponseRef{Ref: "#/components/responses/HTTPError"}) + } + g.spec.AddOperation(openAPIPath(route.Path), route.Method, op) +} + +func (g *Generator) sourceInferredManifestSuccessBody(typ RouteManifestType) (RouteManifestType, bool) { + body, ok := manifestStatusWrapperBodyType(typ) + if !ok { + return RouteManifestType{}, false + } + return body, true +} + +func (g *Generator) addManifestInput(op *openapi3.Operation, route RouteManifestRoute, typ RouteManifestType) { + typ = derefManifestType(typ) + if typ.Kind != "struct" { + return + } + body := openapi3.NewObjectSchema() + body.Properties = openapi3.Schemas{} + bodyMediaType := "application/json" + pathParams := pathParamNames(route.Path) + + for _, field := range typ.Fields { + if field.PkgPath != "" { + continue + } + if name := manifestTagName(field.Tag, "uri"); name != "" { + if _, ok := pathParams[name]; !ok { + g.warnf(`%s %s: uri parameter %q does not match path parameters %s`, route.Method, route.Path, name, formatParamNames(pathParams)) + } + op.AddParameter(g.manifestParameter(name, "path", true, typ, field)) + continue + } + if name := manifestTagName(field.Tag, "query"); name != "" { + op.AddParameter(g.manifestParameter(name, "query", manifestHasBinding(field.Tag, "required"), typ, field)) + continue + } + if name := manifestTagName(field.Tag, "header"); name != "" { + op.AddParameter(g.manifestParameter(name, "header", manifestHasBinding(field.Tag, "required"), typ, field)) + continue + } + if manifestTagName(field.Tag, "context") != "" { + continue + } + + name := manifestTagName(field.Tag, "form") + if name != "" { + bodyMediaType = "application/x-www-form-urlencoded" + } + if name == "" { + name = manifestTagName(field.Tag, "json") + } + if name == "" { + name = lowerFirst(field.Name) + } + body.Properties[name] = g.manifestFieldSchemaRef(typ, field) + if manifestHasBinding(field.Tag, "required") { + body.Required = append(body.Required, name) + } + } + if len(body.Properties) > 0 { + op.RequestBody = &openapi3.RequestBodyRef{Value: openapi3.NewRequestBody(). + WithRequired(len(body.Required) > 0). + WithSchema(body, []string{bodyMediaType})} + } +} + +func (g *Generator) manifestParameter(name, in string, required bool, owner RouteManifestType, field RouteManifestField) *openapi3.Parameter { + return &openapi3.Parameter{ + Name: name, + In: in, + Required: required, + Schema: g.manifestFieldSchemaRef(owner, field), + } +} + +func (g *Generator) manifestFieldSchemaRef(owner RouteManifestType, field RouteManifestField) *openapi3.SchemaRef { + ref := g.manifestSchemaRef(field.Type) + if ref.Value != nil && owner.Name != "" { + if text := g.docs.fieldDoc(owner.Name, field.Name); text != "" { + ref.Value.Description = text + } + } + return ref +} + +func (g *Generator) manifestSuccessResponse(status int, typ RouteManifestType) *openapi3.Response { + response := openapi3.NewResponse().WithDescription(http.StatusText(status)) + if status == http.StatusNoContent || status == http.StatusResetContent { + return response + } + if derefManifestType(typ).Kind == "string" { + return response.WithContent(openapi3.Content{ + "text/plain": openapi3.NewMediaType().WithSchemaRef(g.manifestSchemaRef(typ)), + }) + } + return response.WithJSONSchemaRef(g.manifestSchemaRef(typ)) +} + +func (g *Generator) manifestSchemaRef(typ RouteManifestType) *openapi3.SchemaRef { + typ = derefManifestType(typ) + if typ.Kind == "struct" && !manifestIsTimeType(typ) { + return g.manifestComponentSchemaRef(typ) + } + return &openapi3.SchemaRef{Value: g.manifestSchema(typ)} +} + +func (g *Generator) manifestComponentSchemaRef(typ RouteManifestType) *openapi3.SchemaRef { + key := manifestTypeKey(typ) + if name, ok := g.manifestSchemaNames[key]; ok { + return &openapi3.SchemaRef{Ref: "#/components/schemas/" + name} + } + name := g.uniqueManifestSchemaName(typ) + g.manifestSchemaNames[key] = name + g.manifestSchemaByName[name] = key + g.spec.Components.Schemas[name] = &openapi3.SchemaRef{Value: g.manifestObjectSchema(typ)} + return &openapi3.SchemaRef{Ref: "#/components/schemas/" + name} +} + +func (g *Generator) uniqueManifestSchemaName(typ RouteManifestType) string { + short := manifestSchemaName(typ) + key := manifestTypeKey(typ) + if existing, ok := g.manifestSchemaByName[short]; !ok || existing == key { + return short + } + long := sanitizeName(typ.PkgPath + "_" + typ.Name) + if existing, ok := g.manifestSchemaByName[long]; !ok || existing == key { + return long + } + for i := 2; ; i++ { + candidate := fmt.Sprintf("%s_%d", long, i) + if _, exists := g.manifestSchemaByName[candidate]; !exists { + return candidate + } + } +} + +func scalarSchema(kind string) (*openapi3.Schema, bool) { + switch kind { + case "bool": + return openapi3.NewBoolSchema(), true + case "int", "int8", "int16", "int32", "uint", "uint8", "uint16", "uint32": + return openapi3.NewInt32Schema(), true + case "int64", "uint64": + return openapi3.NewInt64Schema(), true + case "float32": + schema := openapi3.NewFloat64Schema() + schema.Format = "float" + return schema, true + case "float64": + return openapi3.NewFloat64Schema(), true + case "string": + return openapi3.NewStringSchema(), true + } + return nil, false +} + +func (g *Generator) manifestSchema(typ RouteManifestType) *openapi3.Schema { + nullable := false + for typ.Kind == "ptr" || typ.Kind == "pointer" { + nullable = true + if typ.Elem == nil { + break + } + typ = *typ.Elem + } + if manifestIsTimeType(typ) { + schema := openapi3.NewDateTimeSchema() + if nullable { + schema.Nullable = true + } + return schema + } + var schema *openapi3.Schema + if scalar, ok := scalarSchema(typ.Kind); ok { + schema = scalar + } else { + switch typ.Kind { + case "slice", "array": + if typ.Kind == "slice" && typ.Elem != nil && typ.Elem.Kind == "uint8" { + schema = openapi3.NewBytesSchema() + break + } + schema = openapi3.NewArraySchema() + if typ.Elem != nil { + schema.Items = g.manifestSchemaRef(*typ.Elem) + } + case "map": + schema = openapi3.NewObjectSchema() + if typ.Key != nil && typ.Key.Kind == "string" && typ.Elem != nil { + schema.WithAdditionalProperties(g.manifestSchema(*typ.Elem)) + } else { + schema.WithAnyAdditionalProperties() + } + case "struct": + if manifestIsTimeType(typ) { + schema = openapi3.NewDateTimeSchema() + break + } + schema = g.manifestObjectSchema(typ) + case "interface": + schema = openapi3.NewObjectSchema().WithAnyAdditionalProperties() + default: + schema = openapi3.NewSchema() + } + } + if nullable { + schema.Nullable = true + } + return schema +} + +func (g *Generator) manifestObjectSchema(typ RouteManifestType) *openapi3.Schema { + schema := openapi3.NewObjectSchema() + schema.Properties = openapi3.Schemas{} + for _, field := range typ.Fields { + if field.PkgPath != "" { + continue + } + name := manifestTagName(field.Tag, "json") + if name == "" { + name = lowerFirst(field.Name) + } + schema.Properties[name] = g.manifestFieldSchemaRef(typ, field) + if manifestHasBinding(field.Tag, "required") { + schema.Required = append(schema.Required, name) + } + } + return schema +} + +func manifestTypeKey(typ RouteManifestType) string { + if len(typ.TypeArgs) > 0 { + parts := make([]string, 0, len(typ.TypeArgs)) + for _, arg := range typ.TypeArgs { + parts = append(parts, manifestTypeKey(arg)) + } + return typ.PkgPath + "." + typ.Name + "[" + strings.Join(parts, ",") + "]" + } + if typ.PkgPath != "" || typ.Name != "" { + return typ.PkgPath + "." + typ.Name + } + if typ.String != "" { + return typ.String + } + return manifestStructuralTypeKey(typ) +} + +func manifestStructuralTypeKey(typ RouteManifestType) string { + var b strings.Builder + b.WriteString(typ.Kind) + if typ.Key != nil { + b.WriteString("{key:") + b.WriteString(manifestTypeKey(*typ.Key)) + b.WriteString("}") + } + if typ.Elem != nil { + b.WriteString("{elem:") + b.WriteString(manifestTypeKey(*typ.Elem)) + b.WriteString("}") + } + if len(typ.Fields) > 0 { + b.WriteString("{fields:") + for _, field := range typ.Fields { + b.WriteString(field.Name) + b.WriteByte(':') + b.WriteString(field.Tag) + b.WriteByte(':') + if field.Anonymous { + b.WriteByte('1') + } else { + b.WriteByte('0') + } + b.WriteByte(':') + b.WriteString(manifestTypeKey(field.Type)) + b.WriteByte(';') + } + b.WriteByte('}') + } + return b.String() +} + +func manifestSchemaName(typ RouteManifestType) string { + pkg := typ.PkgPath + if idx := strings.LastIndex(pkg, "/"); idx >= 0 { + pkg = pkg[idx+1:] + } + name := manifestTypeDisplayName(typ) + if name == "" { + name = manifestShortTypeName(typ.String) + } + if pkg == "" { + return sanitizeName(name) + } + return sanitizeName(pkg + "_" + name) +} + +func manifestTypeDisplayName(typ RouteManifestType) string { + if len(typ.TypeArgs) == 0 && strings.Contains(typ.Name, "[") { + return manifestShortTypeName(typ.Name) + } + name := manifestShortTypeToken(typ.Name) + if name == "" { + return "" + } + for _, arg := range typ.TypeArgs { + if short := manifestTypeDisplayName(arg); short != "" { + name += "_" + short + continue + } + if short := manifestShortTypeName(arg.String); short != "" { + name += "_" + short + } + } + return name +} + +func manifestShortTypeName(name string) string { + // Legacy manifests encoded generic type arguments only in Name/String. + // New manifests should use RouteManifestType.TypeArgs instead. + name = strings.TrimSpace(name) + if name == "" { + return "" + } + open := strings.IndexByte(name, '[') + if open < 0 { + return manifestShortTypeToken(name) + } + + base := manifestShortTypeToken(name[:open]) + close := matchingBracket(name, open) + if close < 0 { + return manifestShortTypeToken(name) + } + + parts := []string{base} + for _, arg := range splitManifestTypeArgs(name[open+1 : close]) { + if short := manifestShortTypeName(arg); short != "" { + parts = append(parts, short) + } + } + if close+1 < len(name) { + if suffix := manifestShortTypeName(name[close+1:]); suffix != "" { + parts = append(parts, suffix) + } + } + return strings.Join(parts, "_") +} + +func manifestShortTypeToken(token string) string { + token = strings.TrimSpace(token) + token = strings.TrimPrefix(token, "*") + token = strings.TrimPrefix(token, "[]") + if idx := strings.LastIndexByte(token, '/'); idx >= 0 { + token = token[idx+1:] + } + if idx := strings.LastIndexByte(token, '.'); idx >= 0 { + token = token[idx+1:] + } + return token +} + +func matchingBracket(value string, open int) int { + depth := 0 + for i := open; i < len(value); i++ { + switch value[i] { + case '[': + depth++ + case ']': + depth-- + if depth == 0 { + return i + } + } + } + return -1 +} + +func splitManifestTypeArgs(value string) []string { + var args []string + start := 0 + depth := 0 + for i := 0; i < len(value); i++ { + switch value[i] { + case '[': + depth++ + case ']': + depth-- + case ',': + if depth == 0 { + args = append(args, value[start:i]) + start = i + 1 + } + } + } + args = append(args, value[start:]) + return args +} + func (g *Generator) generateRoute(route fox.RouteInfo) { defer func() { if r := recover(); r != nil { @@ -430,48 +890,35 @@ func (g *Generator) schema(typ reflect.Type) *openapi3.Schema { } var schema *openapi3.Schema - switch typ.Kind() { - case reflect.Bool: - schema = openapi3.NewBoolSchema() - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32: - schema = openapi3.NewInt32Schema() - case reflect.Int64: - schema = openapi3.NewInt64Schema() - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32: - schema = openapi3.NewInt32Schema() - case reflect.Uint64: - schema = openapi3.NewInt64Schema() - case reflect.Float32: - schema = openapi3.NewFloat64Schema() - schema.Format = "float" - case reflect.Float64: - schema = openapi3.NewFloat64Schema() - case reflect.String: - schema = openapi3.NewStringSchema() - case reflect.Slice, reflect.Array: - if typ.Elem().Kind() == reflect.Uint8 { - schema = openapi3.NewBytesSchema() - break - } - schema = openapi3.NewArraySchema() - schema.Items = g.schemaRef(typ.Elem()) - case reflect.Map: - schema = openapi3.NewObjectSchema() - if typ.Key().Kind() == reflect.String { - schema.WithAdditionalProperties(g.schema(typ.Elem())) - } else { - schema.WithAnyAdditionalProperties() - } - case reflect.Struct: - if typ == reflect.TypeOf(time.Time{}) { - schema = openapi3.NewDateTimeSchema() - break + if scalar, ok := scalarSchema(typ.Kind().String()); ok { + schema = scalar + } else { + switch typ.Kind() { + case reflect.Slice, reflect.Array: + if typ.Elem().Kind() == reflect.Uint8 { + schema = openapi3.NewBytesSchema() + break + } + schema = openapi3.NewArraySchema() + schema.Items = g.schemaRef(typ.Elem()) + case reflect.Map: + schema = openapi3.NewObjectSchema() + if typ.Key().Kind() == reflect.String { + schema.WithAdditionalProperties(g.schema(typ.Elem())) + } else { + schema.WithAnyAdditionalProperties() + } + case reflect.Struct: + if typ == reflect.TypeOf(time.Time{}) { + schema = openapi3.NewDateTimeSchema() + break + } + schema = g.objectSchema(typ) + case reflect.Interface: + schema = openapi3.NewObjectSchema().WithAnyAdditionalProperties() + default: + schema = openapi3.NewSchema() } - schema = g.objectSchema(typ) - case reflect.Interface: - schema = openapi3.NewObjectSchema().WithAnyAdditionalProperties() - default: - schema = openapi3.NewSchema() } if nullable { @@ -533,6 +980,11 @@ func operationID(route fox.RouteInfo) string { return sanitizeName(name) } +// CleanHandlerName strips runtime decorations from a Go handler symbol. +func CleanHandlerName(name string) string { + return cleanHandlerName(name) +} + // cleanHandlerName strips runtime decorations that pollute operationIds: // - "-fm" suffix on method-value (bound method) names // - ".funcN" trailing closure markers diff --git a/route_manifest.go b/route_manifest.go new file mode 100644 index 0000000..27b17bc --- /dev/null +++ b/route_manifest.go @@ -0,0 +1,185 @@ +package openapi + +import ( + "reflect" + "strings" +) + +// RouteManifestVersion is the supported Fox route manifest format version. +const RouteManifestVersion = "fox.route-manifest/v1" + +// RouteManifest is the route registry exchange format written by Fox. +type RouteManifest struct { + Version string `json:"version"` + Routes []RouteManifestRoute `json:"routes"` +} + +// RouteManifestRoute describes one registered Fox route. +type RouteManifestRoute struct { + Method string `json:"method"` + Path string `json:"path"` + Handler string `json:"handler,omitempty"` + HandlerName string `json:"handlerName,omitempty"` + HandlerType string `json:"handlerType,omitempty"` + Inputs []string `json:"inputs,omitempty"` + Results []string `json:"results,omitempty"` + InputTypes []RouteManifestType `json:"inputTypes,omitempty"` + ResultTypes []RouteManifestType `json:"resultTypes,omitempty"` +} + +func (route RouteManifestRoute) HandlerSymbol() string { + if route.Handler != "" { + return route.Handler + } + return route.HandlerName +} + +// RouteManifestType is a serializable subset of Go reflect.Type. +type RouteManifestType struct { + Kind string `json:"kind"` + String string `json:"string,omitempty"` + Name string `json:"name,omitempty"` + PkgPath string `json:"pkgPath,omitempty"` + TypeArgs []RouteManifestType `json:"typeArgs,omitempty"` + Key *RouteManifestType `json:"key,omitempty"` + Elem *RouteManifestType `json:"elem,omitempty"` + Fields []RouteManifestField `json:"fields,omitempty"` +} + +// RouteManifestField is a serializable subset of Go reflect.StructField. +type RouteManifestField struct { + Name string `json:"name"` + PkgPath string `json:"pkgPath,omitempty"` + Tag string `json:"tag,omitempty"` + Anonymous bool `json:"anonymous,omitempty"` + Type RouteManifestType `json:"type"` +} + +func manifestRouteReturnsError(route RouteManifestRoute) bool { + for _, result := range route.ResultTypes { + if manifestIsErrorType(result) { + return true + } + } + for _, result := range route.Results { + if result == "error" { + return true + } + } + return false +} + +func manifestSuccessBody(route RouteManifestRoute) (RouteManifestType, bool) { + if len(route.ResultTypes) == 0 { + return RouteManifestType{}, false + } + first := derefManifestType(route.ResultTypes[0]) + if manifestIsErrorType(first) { + return RouteManifestType{}, false + } + return route.ResultTypes[0], true +} + +func manifestRequestBody(route RouteManifestRoute) (RouteManifestType, bool) { + for _, input := range route.InputTypes { + if manifestIsFoxContext(input) { + continue + } + return input, true + } + return RouteManifestType{}, false +} + +func manifestStatusWrapperBodyType(typ RouteManifestType) (RouteManifestType, bool) { + typ = derefManifestType(typ) + if typ.Kind != "struct" { + return RouteManifestType{}, false + } + for _, field := range typ.Fields { + if strings.EqualFold(field.Name, "data") { + return field.Type, true + } + } + + var body *RouteManifestType + for _, field := range typ.Fields { + if manifestIsStatusField(field) { + continue + } + if body != nil { + return RouteManifestType{}, false + } + body = &field.Type + } + if body == nil { + return RouteManifestType{}, false + } + return *body, true +} + +func manifestIsErrorType(typ RouteManifestType) bool { + typ = derefManifestType(typ) + return typ.Name == "error" || typ.String == "error" +} + +func manifestIsFoxContext(typ RouteManifestType) bool { + typ = derefManifestType(typ) + return typ.Name == "Context" && typ.PkgPath == "github.com/fox-gonic/fox" +} + +func manifestIsTimeType(typ RouteManifestType) bool { + typ = derefManifestType(typ) + return (typ.Name == "Time" && typ.PkgPath == "time") || typ.String == "time.Time" +} + +func manifestIsStatusField(field RouteManifestField) bool { + if strings.EqualFold(field.Name, "status") { + return true + } + switch derefManifestType(field.Type).Kind { + case "int", "int8", "int16", "int32", "int64", + "uint", "uint8", "uint16", "uint32", "uint64": + return true + } + return false +} + +func derefManifestType(typ RouteManifestType) RouteManifestType { + for typ.Kind == "ptr" || typ.Kind == "pointer" { + if typ.Elem == nil { + return typ + } + typ = *typ.Elem + } + return typ +} + +func manifestTagName(tag, key string) string { + value := reflect.StructTag(tag).Get(key) + if value == "" || value == "-" { + return "" + } + name := splitManifestTag(value) + if name == "-" { + return "" + } + return name +} + +func manifestHasBinding(tag, rule string) bool { + value := reflect.StructTag(tag).Get("binding") + for value != "" { + var part string + part, value, _ = strings.Cut(value, ",") + name, _, _ := strings.Cut(part, "=") + if name == rule { + return true + } + } + return false +} + +func splitManifestTag(value string) string { + name, _, _ := strings.Cut(value, ",") + return name +} diff --git a/route_manifest_test.go b/route_manifest_test.go new file mode 100644 index 0000000..6287c1c --- /dev/null +++ b/route_manifest_test.go @@ -0,0 +1,247 @@ +package openapi + +import ( + "net/http" + "testing" +) + +type manifestStatusResponse[T any] struct { + status int + data T +} + +type manifestTemplatePayload struct { + ID string `json:"id"` +} + +func manifestStatusResponseWithStatus[T any](status int, data T) manifestStatusResponse[T] { + return manifestStatusResponse[T]{ + status: status, + data: data, + } +} + +func createManifestTemplateForRouteManifest() (manifestStatusResponse[manifestTemplatePayload], error) { + return manifestStatusResponseWithStatus(http.StatusAccepted, manifestTemplatePayload{}), nil +} + +func TestNewFromRouteManifestGeneratesPaths(t *testing.T) { + manifest := RouteManifest{ + Version: "fox.route-manifest/v1", + Routes: []RouteManifestRoute{{ + Method: http.MethodGet, + Path: "/users/:id", + Handler: "example.com/app/internal/handler.GetUser", + InputTypes: []RouteManifestType{ + { + Kind: "struct", + Name: "GetUserRequest", + PkgPath: "example.com/app/internal/handler", + Fields: []RouteManifestField{ + {Name: "ID", Tag: `uri:"id" binding:"required"`, Type: RouteManifestType{Kind: "string", Name: "string"}}, + {Name: "Search", Tag: `query:"search"`, Type: RouteManifestType{Kind: "string", Name: "string"}}, + }, + }, + }, + ResultTypes: []RouteManifestType{ + { + Kind: "struct", + Name: "User", + PkgPath: "example.com/app/internal/handler", + Fields: []RouteManifestField{ + {Name: "ID", Tag: `json:"id"`, Type: RouteManifestType{Kind: "string", Name: "string"}}, + {Name: "Name", Tag: `json:"name"`, Type: RouteManifestType{Kind: "string", Name: "string"}}, + }, + }, + {Kind: "interface", Name: "error"}, + }, + }}, + } + + spec := NewFromRouteManifest(manifest, Info("Manifest API", "1.0.0")).Spec() + path := spec.Paths.Value("/users/{id}") + if path == nil || path.Get == nil { + t.Fatalf("expected GET /users/{id}, got %#v", spec.Paths.Map()) + } + if path.Get.OperationID != "example_com_app_internal_handler_GetUser" { + t.Fatalf("operationId = %q", path.Get.OperationID) + } + if len(path.Get.Parameters) != 2 || path.Get.Parameters[0].Value.Name != "id" { + t.Fatalf("parameters = %#v", path.Get.Parameters) + } + if path.Get.Parameters[1].Value.Name != "search" { + t.Fatalf("parameters = %#v", path.Get.Parameters) + } + response := path.Get.Responses.Value("200") + if response == nil { + t.Fatalf("missing 200 response: %#v", path.Get.Responses.Map()) + } + content := response.Value.Content.Get("application/json") + if content == nil || content.Schema.Ref != "#/components/schemas/handler_User" { + t.Fatalf("response content = %#v", response.Value.Content) + } + userSchema := spec.Components.Schemas["handler_User"] + if userSchema == nil || userSchema.Value.Properties["id"] == nil { + t.Fatalf("missing user schema: %#v", spec.Components.Schemas) + } + if path.Get.Responses.Value("default") == nil { + t.Fatalf("missing default response: %#v", path.Get.Responses.Map()) + } +} + +func TestNewFromRouteManifestOperationIDFallsBackToMethodPath(t *testing.T) { + manifest := RouteManifest{ + Version: "fox.route-manifest/v1", + Routes: []RouteManifestRoute{{ + Method: http.MethodGet, + Path: "/users/:id", + }}, + } + + spec := NewFromRouteManifest(manifest, Info("Manifest API", "1.0.0")).Spec() + path := spec.Paths.Value("/users/{id}") + if path == nil || path.Get == nil { + t.Fatalf("expected GET /users/{id}, got %#v", spec.Paths.Map()) + } + if path.Get.OperationID != "GET__users__id" { + t.Fatalf("operationId = %q", path.Get.OperationID) + } +} + +func TestNewFromRouteManifestUnwrapsStatusResponseBody(t *testing.T) { + manifest := RouteManifest{ + Version: "fox.route-manifest/v1", + Routes: []RouteManifestRoute{{ + Method: http.MethodPost, + Path: "/sandbox/templates", + Handler: "github.com/fox-gonic/openapi.createManifestTemplateForRouteManifest", + ResultTypes: []RouteManifestType{ + { + Kind: "struct", + Name: "StatusResponse", + PkgPath: "github.com/aonesuite/infra/internal/products/sandbox/handler", + TypeArgs: []RouteManifestType{{ + Kind: "struct", + Name: "TemplateResponse", + PkgPath: "github.com/aonesuite/infra/internal/products/sandbox/handler", + }}, + Fields: []RouteManifestField{ + {Name: "status", PkgPath: "github.com/aonesuite/infra/internal/products/sandbox/handler", Type: RouteManifestType{Kind: "int", Name: "int"}}, + {Name: "data", PkgPath: "github.com/aonesuite/infra/internal/products/sandbox/handler", Type: RouteManifestType{ + Kind: "struct", + Name: "TemplateResponse", + PkgPath: "github.com/aonesuite/infra/internal/products/sandbox/handler", + Fields: []RouteManifestField{ + {Name: "ID", Tag: `json:"id"`, Type: RouteManifestType{Kind: "string", Name: "string"}}, + }, + }}, + }, + }, + }, + }}, + } + + spec := NewFromRouteManifest(manifest, Info("Manifest API", "1.0.0"), Source([]string{"./..."}, IncludeTestFiles())).Spec() + response := spec.Paths.Value("/sandbox/templates").Post.Responses.Value("202") + if response == nil { + t.Fatalf("missing 202 response") + } + content := response.Value.Content.Get("application/json") + if content == nil { + t.Fatalf("missing response content") + } + const wantRef = "#/components/schemas/handler_TemplateResponse" + if content.Schema.Ref != wantRef { + t.Fatalf("response schema ref = %q, want %q", content.Schema.Ref, wantRef) + } + if spec.Components.Schemas["handler_StatusResponse_TemplateResponse"] != nil { + t.Fatalf("generated status wrapper schema: %#v", spec.Components.Schemas) + } + if spec.Components.Schemas["handler_StatusResponse_github_com_aonesuite_infra_internal_products_sandbox_handler_TemplateResponse"] != nil { + t.Fatalf("generated long generic schema name: %#v", spec.Components.Schemas) + } +} + +func TestNewFromRouteManifestUsesByteSchema(t *testing.T) { + manifest := RouteManifest{ + Version: "fox.route-manifest/v1", + Routes: []RouteManifestRoute{{ + Method: http.MethodGet, + Path: "/files/:id/content", + Handler: "example.com/app/internal/handler.GetFileContent", + ResultTypes: []RouteManifestType{{ + Kind: "slice", + Elem: &RouteManifestType{Kind: "uint8", Name: "uint8"}, + }}, + }}, + } + + spec := NewFromRouteManifest(manifest, Info("Manifest API", "1.0.0")).Spec() + response := spec.Paths.Value("/files/{id}/content").Get.Responses.Value("200") + if response == nil { + t.Fatalf("missing 200 response") + } + content := response.Value.Content.Get("application/json") + if content == nil || content.Schema == nil || content.Schema.Value == nil { + t.Fatalf("missing response schema: %#v", response.Value.Content) + } + schema := content.Schema.Value + if schema.Type == nil || !schema.Type.Is("string") || schema.Format != "byte" { + t.Fatalf("schema = %#v, want string byte schema", schema) + } +} + +func TestManifestSchemaNameUsesStructuredTypeArgs(t *testing.T) { + name := manifestSchemaName(RouteManifestType{ + Kind: "struct", + Name: "StatusResponse", + PkgPath: "github.com/aonesuite/infra/internal/products/sandbox/handler", + TypeArgs: []RouteManifestType{{ + Kind: "struct", + Name: "TemplateResponse", + PkgPath: "github.com/aonesuite/infra/internal/products/sandbox/handler", + }}, + }) + + if name != "handler_StatusResponse_TemplateResponse" { + t.Fatalf("schema name = %q", name) + } +} + +func TestManifestSchemaNameKeepsLegacyGenericStringCompatibility(t *testing.T) { + name := manifestSchemaName(RouteManifestType{ + Kind: "struct", + Name: "StatusResponse[github.com/aonesuite/infra/internal/products/sandbox/handler.TemplateResponse]", + PkgPath: "github.com/aonesuite/infra/internal/products/sandbox/handler", + }) + + if name != "handler_StatusResponse_TemplateResponse" { + t.Fatalf("schema name = %q", name) + } +} + +func TestManifestTypeKeyDistinguishesAnonymousStructs(t *testing.T) { + first := RouteManifestType{ + Kind: "struct", + Fields: []RouteManifestField{{ + Name: "ID", + Tag: `json:"id"`, + Type: RouteManifestType{Kind: "string", Name: "string"}, + }}, + } + second := RouteManifestType{ + Kind: "struct", + Fields: []RouteManifestField{{ + Name: "Name", + Tag: `json:"name"`, + Type: RouteManifestType{Kind: "string", Name: "string"}, + }}, + } + + if manifestTypeKey(first) == "" { + t.Fatal("first key is empty") + } + if manifestTypeKey(first) == manifestTypeKey(second) { + t.Fatalf("anonymous struct keys collided: %q", manifestTypeKey(first)) + } +}