diff --git a/README.md b/README.md index bbc2bf9..48cc847 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,11 @@ unless it uses optional OpenAPI metadata hooks or the library API directly. go install github.com/fox-gonic/openapi/cmd/fox-openapi@latest ``` -For reproducible CI, pin the version used to generate committed specs: +For reproducible CI, pin the version used to generate committed specs. Replace +`vX.Y.Z` with the release you want the downstream repository to use: ```bash -go install github.com/fox-gonic/openapi/cmd/fox-openapi@v0.3.0 +go install github.com/fox-gonic/openapi/cmd/fox-openapi@vX.Y.Z ``` For local development in this repository: @@ -29,7 +30,7 @@ go run ./cmd/fox-openapi version ## Quickstart -Expose an entry function that registers routes and returns a `*fox.Engine`. +Expose one engine factory function that registers routes and returns a `*fox.Engine`. It should not call `Run`, open listeners, or start infrastructure that is not needed for route registration. @@ -57,8 +58,8 @@ fox-openapi serve --addr 127.0.0.1:8765 When `entry` is omitted from the config and `--entry` is not passed, the CLI scans `sources` (default `./...`) for an exported function whose signature matches one of the supported entry shapes. If exactly one is found, it is -used. If multiple are found, the CLI fails with the candidate list — pick one -with `--entry` or annotate the canonical entry with a doc comment marker: +used. If multiple are found, prefer annotating the canonical entry with a doc +comment marker: ```go // NewEngine builds the production HTTP engine. @@ -71,15 +72,37 @@ When at least one function carries the `fox-openapi:entry` marker, only marked candidates are considered, so adding the marker disambiguates without deleting other entry-shaped helpers. +`--entry` is still available for scripts, CI, and unusual layouts that need to +pin the function explicitly: + +```bash +fox-openapi generate \ + --entry github.com/acme/myapp/internal/server.NewEngine +``` + +Supported entry signatures are: + +```go +func NewEngine() *fox.Engine +func NewEngine() (*fox.Engine, error) +func NewEngine(context.Context) *fox.Engine +func NewEngine(context.Context) (*fox.Engine, error) +func NewEngine(context.Context, *Config) *fox.Engine +func NewEngine(context.Context, *Config) (*fox.Engine, error) +``` + +`*Config` represents your application's own configuration struct type, not a +fox-openapi-provided type. + `serve` exposes `/openapi.yaml`, `/openapi.json`, `/docs`, `/scalar`, and -`/redoc` with embedded offline UI assets. +`/redoc` with embedded offline UI assets. It watches Go files by default and +regenerates the preview when source changes. For small projects, no config file is required. Pass flags only when you want -to override defaults: +to override output or metadata defaults: ```bash fox-openapi \ - --entry github.com/acme/myapp/internal/server.NewEngine \ --out api/openapi.yaml \ --title "Acme API" ``` @@ -87,12 +110,12 @@ fox-openapi \ Use `fox-openapi init` only when you want to commit a config file for shared metadata such as title, servers, tags, security schemes, or entry config. -The CLI builds an isolated temporary driver. For basic generation, the -application module does not need a `tools.go` file or a committed direct -`github.com/fox-gonic/openapi` requirement; the driver build resolves that -temporary dependency and restores `go.mod`/`go.sum` afterward. Add a direct -requirement only when application code imports OpenAPI metadata hooks or -library APIs. +The CLI builds an isolated temporary driver for entry-based generation. For +basic generation, the application module does not need a `tools.go` file or a +committed direct `github.com/fox-gonic/openapi` requirement; the driver build +resolves that temporary dependency and restores `go.mod`/`go.sum` afterward. +Add a direct requirement only when application code imports OpenAPI metadata +hooks or library APIs. ## Route Manifest Mode @@ -154,42 +177,6 @@ loads the application packages from `workdir` to enrich request and response types, including aliases, generic wrappers, and handlers in `_test.go` when `includeTestFiles` is enabled. -## Entry Functions - -`entry` must name an exported function with one of these signatures: - -```go -func NewEngine() *fox.Engine -func NewEngine() (*fox.Engine, error) -func NewEngine(context.Context) *fox.Engine -func NewEngine(context.Context) (*fox.Engine, error) -func NewEngine(context.Context, *Config) *fox.Engine -func NewEngine(context.Context, *Config) (*fox.Engine, error) -``` - -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.LoadForOpenAPI - path: config.yaml -``` - -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 Paths follow standard go-tooling conventions: @@ -198,7 +185,7 @@ Paths follow standard go-tooling conventions: to the **current working directory** (where you invoked the command). - **YAML fields** (`out`, `entryConfig.path`, `workdir`): relative to the **directory containing the config file**, so `fox-openapi.yaml` and the - artefacts it points to keep a stable layout regardless of where you run. + artifacts it points to keep a stable layout regardless of where you run. - **Positional path** (`fox-openapi generate ./internal/aone`): narrows where the CLI **looks for the entry function**. It does **not** narrow source scanning — `sources` (default `./...`) still drives comment extraction so @@ -212,6 +199,63 @@ fox-openapi generate internal/aone --out api/openapi.yaml # wrote ~/myapp/api/openapi.yaml ← relative to CWD, not the scanned dir ``` +## Filtered Specs + +fox-openapi can derive narrower OpenAPI documents from the full generated +contract. The first supported CLI shape is intentionally simple: remove +operations whose extension has a specific scalar value, then optionally prune +components that are no longer referenced. + +For example, handlers can mark internal operations in source comments: + +```go +// List API keys. +// +// openapi: +// +// x-public: false +func listAPIKeys(ctx *fox.Context) (ListAPIKeysResponse, error) { + return ListAPIKeysResponse{}, nil +} +``` + +Then generate a public-only spec: + +```bash +fox-openapi generate \ + --out api/public.openapi.yaml \ + --filter "x-public != false" \ + --filter "x-product = sandbox || x-product = account" \ + --prune-unused-components +``` + +The same settings can live in `fox-openapi.yaml`: + +```yaml +out: api/public.openapi.yaml +filters: + - x-public != false + - x-product = sandbox || x-product = account +pruneUnusedComponents: true +``` + +The library API exposes the generic pipeline directly: + +```go +spec := openapi.New(engine, + openapi.WithFilters( + openapi.FilterOperations(func(op openapi.OperationContext) bool { + return op.ExtensionBoolDefault("x-public", true) + }), + openapi.PruneUnusedComponents(), + ), +) +``` + +Filtering is a post-generation step. Explicit metadata, inferred responses, and +source comment enrichment still happen first, so derived specs keep the same +contract semantics as the full document. + ## Config `fox-openapi init` writes a config like: @@ -242,8 +286,16 @@ Supported config keys: - `servers`: list of `url` and optional `description`. - `tags`: top-level OpenAPI tag registry. - `securitySchemes`: serializable HTTP, API key, OAuth2, or OpenID Connect schemes. +- `filters`: operation filter expressions. Supported operators are `=`, `==`, + and `!=`. Use `||` inside one expression for OR; repeat filters to combine + expressions with AND. +- `pruneUnusedComponents`: remove components no longer referenced after filters. - `metadataHook`: optional advanced Go hook. -- `entryConfig`: optional `loader` and `path` for config-taking entries. +- `entryConfig`: optional `path` and `loader` for config-taking entries. When + `path` is set, fox-openapi first looks for a package-level + `Load(string) (*Config, error)` function in the config type's package; set + `loader` only when loading needs a non-standard function. When omitted, + config-taking entries receive `nil` for compatibility. CLI flags override config values. Config values override defaults. @@ -251,8 +303,8 @@ CLI flags override config values. Config values override defaults. ```bash fox-openapi init --entry internal/server.NewEngine --title "Acme API" -fox-openapi --entry github.com/acme/myapp/internal/server.NewEngine --out api/openapi.yaml --title "Acme API" -fox-openapi generate --entry github.com/acme/myapp/internal/server.NewEngine --out api/openapi.yaml --title "Acme API" +fox-openapi --out api/openapi.yaml --title "Acme API" +fox-openapi generate --entry github.com/acme/myapp/internal/server.NewEngine fox-openapi generate --route-manifest api/routes.manifest.json --out api/openapi.yaml --title "Acme API" fox-openapi check fox-openapi serve --addr 127.0.0.1:8765 @@ -262,17 +314,20 @@ fox-openapi version `fox-openapi`, `generate`, `check`, and `serve` share the common config flags: - `--config`: config file path, default `fox-openapi.yaml`. -- `--entry`: entry function. +- `--entry`: explicitly pin the entry function when auto-discovery is not enough. - `--out`: output path, default `api/openapi.yaml`. - `--title` and `--version`: OpenAPI info metadata. - `--server`: repeatable OpenAPI server URL. - `--workdir`: user project root. -- `--route-manifest`: Fox route manifest file. +- `--filter`: repeatable operation filter expression, for example + `--filter "x-public != false"`. +- `--prune-unused-components`: remove components no longer referenced after + filters. Advanced flags remain available for scripts and unusual projects but are hidden from normal help: `--format`, `--source`, `--include-test-files`, `--metadata-hook`, `--entry-config-loader`, `--entry-config-path`, -`--keep-driver`, and `--verbose`. +`--route-manifest`, `--keep-driver`, and `--verbose`. `serve` also supports `--addr`, repeatable `--ui`, `--watch`, and `--open`. @@ -353,6 +408,7 @@ func main() { openapi.Server("https://api.example.com"), openapi.Source([]string{"."}), openapi.Operation("GET", "/users/:id", openapi.Tags("users")), + openapi.WithFilters(openapi.PruneUnusedComponents()), ) openapi.Mount(router, spec) @@ -387,12 +443,15 @@ The generator covers: - OpenAPI version `3.0.3` - `info`, `servers`, top-level tags, and security schemes - paths and methods from registered Fox routes +- route manifest input when running the application is not desirable - Gin-style path parameters such as `/users/:id` as `/users/{id}` - `uri`, `query`, `header`, `json`, and `form` request fields - operation and schema descriptions from source comments -- explicit operation and group metadata -- JSON, form, string, empty, and error responses +- explicit operation and group metadata, including security and extensions +- inferred success responses, explicit responses, status wrappers, no-body + success statuses, and default error responses - reusable component schemas with recursive `$ref` support +- post-generation operation filters and unused component pruning - custom type schema overrides through `openapi.RegisterFormatter` Supported validation tags include `required`, `email`, `url`, `uri`, `uuid`, @@ -433,6 +492,8 @@ For manifest mode, refresh the application-owned manifest before generating: ## Current Limitations The current implementation intentionally does not generate DomainEngine-specific -multi-host specs, custom schema naming overrides, or operation/group tag -assignment directly from YAML config. Use handler comment `openapi:` blocks for -simple operation metadata and `metadataHook` when metadata needs Go values. +multi-host specs or custom schema naming overrides. CLI filtering currently +supports scalar extension equality plus component pruning; use the Go filter API +for richer predicates such as path, method, operation ID, tags, or deprecation. +Use handler comment `openapi:` blocks for simple operation metadata and +`metadataHook` when metadata needs Go values. diff --git a/README.zh-CN.md b/README.zh-CN.md index 0881b96..f492bc0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -12,10 +12,10 @@ go install github.com/fox-gonic/openapi/cmd/fox-openapi@latest ``` -CI 中建议固定生成器版本,保证提交的 spec 可复现: +CI 中建议固定生成器版本,保证提交的 spec 可复现。请把 `vX.Y.Z` 替换为下游仓库实际使用的版本: ```bash -go install github.com/fox-gonic/openapi/cmd/fox-openapi@v0.3.0 +go install github.com/fox-gonic/openapi/cmd/fox-openapi@vX.Y.Z ``` 在本仓库内开发时,可以直接运行: @@ -26,7 +26,7 @@ go run ./cmd/fox-openapi version ## 快速开始 -先暴露一个 entry 函数,用来注册路由并返回 `*fox.Engine`。这个函数不应该调用 `Run`、监听端口,或启动和路由注册无关的基础设施。 +先暴露一个 engine factory 函数,用来注册路由并返回 `*fox.Engine`。这个函数不应该调用 `Run`、监听端口,或启动和路由注册无关的基础设施。 ```go package server @@ -49,7 +49,7 @@ fox-openapi check fox-openapi serve --addr 127.0.0.1:8765 ``` -当配置中省略 `entry` 且未传 `--entry` 时,CLI 会扫描 `sources`(默认 `./...`),查找一个签名符合 entry 形状的导出函数。如果只匹配到一个就直接使用;匹配到多个则报错并列出所有候选 —— 用 `--entry` 选定,或在某个函数的 doc 注释中添加标记: +当配置中省略 `entry` 且未传 `--entry` 时,CLI 会扫描 `sources`(默认 `./...`),查找一个签名符合 entry 形状的导出函数。如果只匹配到一个就直接使用;匹配到多个则报错并列出所有候选。推荐在标准入口的 doc 注释中添加标记: ```go // NewEngine 构建生产环境 HTTP engine。 @@ -60,30 +60,45 @@ func NewEngine() *fox.Engine { ... } 只要至少有一个候选携带 `fox-openapi:entry` 标记,就只考虑被标记的候选,因此可以在不删除其他 entry 形状辅助函数的情况下消除歧义。 -`serve` 会暴露 `/openapi.yaml`、`/openapi.json`、`/docs`、`/scalar` 和 `/redoc`,并使用内置的离线 UI 资源。 +`--entry` 仍然保留给脚本、CI 和特殊目录布局,用来显式固定入口函数: -配置简单的项目不需要创建配置文件。只有想覆盖默认值时才需要传 flags: +```bash +fox-openapi generate \ + --entry github.com/acme/myapp/internal/server.NewEngine +``` + +支持的 entry 签名包括: + +```go +func NewEngine() *fox.Engine +func NewEngine() (*fox.Engine, error) +func NewEngine(context.Context) *fox.Engine +func NewEngine(context.Context) (*fox.Engine, error) +func NewEngine(context.Context, *Config) *fox.Engine +func NewEngine(context.Context, *Config) (*fox.Engine, error) +``` + +这里的 `*Config` 表示你的应用自己的配置结构体类型,不是 fox-openapi 提供的固定类型。 + +`serve` 会暴露 `/openapi.yaml`、`/openapi.json`、`/docs`、`/scalar` 和 `/redoc`,并使用内置的离线 UI 资源。它默认监听 Go 文件变化,并在源码变化后重新生成预览。 + +配置简单的项目不需要创建配置文件。只有想覆盖输出路径或 metadata 默认值时才需要传 flags: ```bash fox-openapi \ - --entry github.com/acme/myapp/internal/server.NewEngine \ --out api/openapi.yaml \ --title "Acme API" ``` -只有在需要提交 title、servers、tags、security schemes 或 entry config 等共享 -metadata 时,才需要使用 `fox-openapi init` 创建配置文件。 +只有在需要提交 title、servers、tags、security schemes 或 entry config 等共享 metadata 时,才需要使用 `fox-openapi init` 创建配置文件。 -CLI 会构建一个隔离的临时 driver。基础生成场景下,业务模块不需要 `tools.go` 文件,也不需要提交直接的 `github.com/fox-gonic/openapi` 依赖;driver 构建会解析这个临时依赖,并在结束后恢复 `go.mod` / `go.sum`。只有业务代码自己 import OpenAPI metadata hook 或 library API 时,才需要直接声明依赖。 +基于 entry 生成时,CLI 会构建一个隔离的临时 driver。基础生成场景下,业务模块不需要 `tools.go` 文件,也不需要提交直接的 `github.com/fox-gonic/openapi` 依赖;driver 构建会解析这个临时依赖,并在结束后恢复 `go.mod` / `go.sum`。只有业务代码自己 import OpenAPI metadata hook 或 library API 时,才需要直接声明依赖。 ## Route Manifest 模式 -从 `v0.3.0` 开始,fox-openapi 可以读取业务应用导出的 route manifest 来生成 -OpenAPI,而不是通过临时 driver 调用应用 entry。当 `NewEngine` 依赖真实运行时对象、 -配置对象或环境初始化,不适合为了 OpenAPI 额外复刻时,推荐使用这个模式。 +从 `v0.3.0` 开始,fox-openapi 可以读取业务应用导出的 route manifest 来生成 OpenAPI,而不是通过临时 driver 调用应用 entry。当 `NewEngine` 依赖真实运行时对象、配置对象或环境初始化,不适合为了 OpenAPI 额外复刻时,推荐使用这个模式。 -manifest 文件由业务应用自己决定什么时候写入。常见做法是在正常启动逻辑旁边增加一个 -非生产用途的 CLI flag: +manifest 文件由业务应用自己决定什么时候写入。常见做法是在正常启动逻辑旁边增加一个非生产用途的 CLI flag: ```go routeManifestPath := flag.String("openapi-route-manifest", "", "write Fox route manifest and exit") @@ -106,8 +121,7 @@ if err := engine.Run(":8080"); err != nil { } ``` -不要在正常生产启动路径中启用这个 flag。fox-openapi 只读取这个文件;业务应用不需要 -import `github.com/fox-gonic/openapi`。 +不要在正常生产启动路径中启用这个 flag。fox-openapi 只读取这个文件;业务应用不需要 import `github.com/fox-gonic/openapi`。 然后配置 fox-openapi 读取生成好的 manifest: @@ -126,57 +140,74 @@ myapp --openapi-route-manifest api/routes.manifest.json fox-openapi generate --route-manifest api/routes.manifest.json --out api/openapi.yaml ``` -Manifest 模式不会运行应用 entry,也不会更新 manifest 文件。它会使用已有 manifest -中的方法、路径、handler 标识、path 参数、operationId、request / response schema,并继续结合源码注释补全文档。如果 manifest 里只有 handler symbol,fox-openapi 会从 `workdir` 加载业务包来补全 request / response 类型,包括 alias、泛型 wrapper,以及开启 `includeTestFiles` 时定义在 `_test.go` 中的 handler。 +Manifest 模式不会运行应用 entry,也不会更新 manifest 文件。它会使用已有 manifest 中的方法、路径、handler 标识、path 参数、operationId、request / response schema,并继续结合源码注释补全文档。如果 manifest 里只有 handler symbol,fox-openapi 会从 `workdir` 加载业务包来补全 request / response 类型,包括 alias、泛型 wrapper,以及开启 `includeTestFiles` 时定义在 `_test.go` 中的 handler。 -## Entry 函数 +## 路径解析 -`entry` 必须指向一个导出函数,并符合以下签名之一: +路径解析遵循标准 Go 工具链约定: -```go -func NewEngine() *fox.Engine -func NewEngine() (*fox.Engine, error) -func NewEngine(context.Context) *fox.Engine -func NewEngine(context.Context) (*fox.Engine, error) -func NewEngine(context.Context, *Config) *fox.Engine -func NewEngine(context.Context, *Config) (*fox.Engine, error) +- **CLI flags**(`--out`、`--config`、`--workdir`、`--entry-config-path`):相对于**当前工作目录**(执行命令时所在的目录)。 +- **YAML 字段**(`out`、`entryConfig.path`、`workdir`):相对于**配置文件所在目录**,这样 `fox-openapi.yaml` 与它指向的产物之间始终保持稳定的相对位置,无论从哪里运行命令。 +- **位置参数**(`fox-openapi generate ./internal/aone`):仅用于**限定 entry 函数的发现范围**。它**不会**收窄源码扫描 —— `sources`(默认 `./...`)依然驱动注释提取,避免子包中的字段/handler 注释丢失。如需显式覆盖扫描范围,请在 YAML 中设置 `sources` 或传 `--source`。 + +```bash +cd ~/myapp +fox-openapi generate internal/aone --out api/openapi.yaml +# 写入 ~/myapp/api/openapi.yaml ← 相对于 CWD,而非被扫描的目录 ``` -对于接收配置的 entry,提供 `entryConfig.path` 后,fox-openapi 会优先在 entry -的配置类型所在包中自动使用包级 `Load(string) (*Config, error)` 函数: +## 过滤后的规格 -```yaml -entryConfig: - path: config.yaml -``` +fox-openapi 可以从完整 OpenAPI contract 中派生更窄的文档。第一版 CLI 形态刻意保持简单:删除某个 extension 等于指定标量值的 operation,然后可选地裁剪不再被引用的 components。 -只有当 loader 不是标准 `Load`,或不在配置类型所在包中时,才需要显式指定 -`entryConfig.loader`: +例如,可以在 handler 注释中标记内部 operation: -```yaml -entryConfig: - loader: github.com/acme/myapp/internal/config.LoadForOpenAPI - path: config.yaml +```go +// List API keys. +// +// openapi: +// +// x-public: false +func listAPIKeys(ctx *fox.Context) (ListAPIKeysResponse, error) { + return ListAPIKeysResponse{}, nil +} ``` -这样 OpenAPI 生成可以直接复用正常的生产 -`NewEngine(context.Context, *Config)`,不需要为了工具额外添加 route-only 分支。 -为了兼容已有项目,完全省略 `entryConfig` 时,fox-openapi 仍会传入 `nil`。 +然后生成 public-only spec: -## 路径解析 +```bash +fox-openapi generate \ + --out api/public.openapi.yaml \ + --filter "x-public != false" \ + --filter "x-product = sandbox || x-product = account" \ + --prune-unused-components +``` -路径解析遵循标准 Go 工具链约定: +同样的设置也可以写进 `fox-openapi.yaml`: -- **CLI flags**(`--out`、`--config`、`--workdir`、`--entry-config-path`):相对于**当前工作目录**(执行命令时所在的目录)。 -- **YAML 字段**(`out`、`entryConfig.path`、`workdir`):相对于**配置文件所在目录**,这样 `fox-openapi.yaml` 与它指向的产物之间始终保持稳定的相对位置,无论从哪里运行命令。 -- **位置参数**(`fox-openapi generate ./internal/aone`):仅用于**限定 entry 函数的发现范围**。它**不会**收窄源码扫描 —— `sources`(默认 `./...`)依然驱动注释提取,避免子包中的字段/handler 注释丢失。如需显式覆盖扫描范围,请在 YAML 中设置 `sources` 或传 `--source`。 +```yaml +out: api/public.openapi.yaml +filters: + - x-public != false + - x-product = sandbox || x-product = account +pruneUnusedComponents: true +``` -```bash -cd ~/myapp -fox-openapi generate internal/aone --out api/openapi.yaml -# 写入 ~/myapp/api/openapi.yaml ← 相对于 CWD,而非被扫描的目录 +Library API 会直接暴露通用过滤 pipeline: + +```go +spec := openapi.New(engine, + openapi.WithFilters( + openapi.FilterOperations(func(op openapi.OperationContext) bool { + return op.ExtensionBoolDefault("x-public", true) + }), + openapi.PruneUnusedComponents(), + ), +) ``` +过滤发生在生成之后。显式 metadata、响应推断和源码注释补全都会先执行,因此派生规格会保留和完整文档一致的 contract 语义。 + ## 配置 `fox-openapi init` 会生成类似下面的配置: @@ -205,8 +236,14 @@ servers: - `servers`:OpenAPI server 列表,每项包含 `url` 和可选的 `description`。 - `tags`:顶层 OpenAPI tag registry。 - `securitySchemes`:可序列化的 HTTP、API key、OAuth2 或 OpenID Connect security scheme。 +- `filters`:operation 过滤表达式。支持的操作符为 `=`、`==` 和 `!=`; + 单个表达式内部可用 `||` 表示 OR,多个 filter 会以 AND 组合。 +- `pruneUnusedComponents`:过滤后删除不再被引用的 components。 - `metadataHook`:可选的高级 Go hook。 -- `entryConfig`:接收配置的 entry 使用的可选 `loader` 和 `path`。 +- `entryConfig`:接收配置的 entry 使用的可选 `path` 和 `loader`。设置 + `path` 后,fox-openapi 会先在配置类型所在包中查找包级 + `Load(string) (*Config, error)` 函数;只有需要非标准加载函数时才需要设置 + `loader`。完全省略时,接收配置的 entry 会收到 `nil`,以兼容已有项目。 CLI flags 会覆盖配置文件,配置文件会覆盖默认值。 @@ -214,8 +251,8 @@ CLI flags 会覆盖配置文件,配置文件会覆盖默认值。 ```bash fox-openapi init --entry internal/server.NewEngine --title "Acme API" -fox-openapi --entry github.com/acme/myapp/internal/server.NewEngine --out api/openapi.yaml --title "Acme API" -fox-openapi generate --entry github.com/acme/myapp/internal/server.NewEngine --out api/openapi.yaml --title "Acme API" +fox-openapi --out api/openapi.yaml --title "Acme API" +fox-openapi generate --entry github.com/acme/myapp/internal/server.NewEngine fox-openapi generate --route-manifest api/routes.manifest.json --out api/openapi.yaml --title "Acme API" fox-openapi check fox-openapi serve --addr 127.0.0.1:8765 @@ -225,16 +262,18 @@ fox-openapi version `fox-openapi`、`generate`、`check` 和 `serve` 共用以下常用配置 flags: - `--config`:配置文件路径,默认 `fox-openapi.yaml`。 -- `--entry`:entry 函数。 +- `--entry`:自动发现不够用时,显式固定 entry 函数。 - `--out`:输出路径,默认 `api/openapi.yaml`。 - `--title` 和 `--version`:OpenAPI info metadata。 - `--server`:可重复传入的 OpenAPI server URL。 - `--workdir`:业务项目根目录。 -- `--route-manifest`:Fox route manifest 文件。 +- `--filter`:可重复传入的 operation 过滤表达式,例如 + `--filter "x-public != false"`。 +- `--prune-unused-components`:过滤后删除不再被引用的 components。 高级 flags 仍然保留给脚本和特殊项目使用,但默认 help 中隐藏:`--format`、 `--source`、`--include-test-files`、`--metadata-hook`、`--entry-config-loader`、 -`--entry-config-path`、`--keep-driver` 和 `--verbose`。 +`--entry-config-path`、`--route-manifest`、`--keep-driver` 和 `--verbose`。 `serve` 还支持 `--addr`、可重复传入的 `--ui`、`--watch` 和 `--open`。 @@ -311,6 +350,7 @@ func main() { openapi.Server("https://api.example.com"), openapi.Source([]string{"."}), openapi.Operation("GET", "/users/:id", openapi.Tags("users")), + openapi.WithFilters(openapi.PruneUnusedComponents()), ) openapi.Mount(router, spec) @@ -344,12 +384,14 @@ for _, warning := range spec.Warnings() { - OpenAPI version `3.0.3` - `info`、`servers`、顶层 tags 和 security schemes - 从已注册 Fox routes 中提取 paths 和 methods +- 当不适合运行应用时,可读取 route manifest 作为输入 - 将 `/users/:id` 这样的 Gin 风格路径参数转换为 `/users/{id}` - `uri`、`query`、`header`、`json` 和 `form` 请求字段 - 从源码注释提取 operation 和 schema 描述 -- 显式 operation 和 group metadata -- JSON、form、string、empty 和 error responses +- 显式 operation 和 group metadata,包括 security 和 extensions +- 推断成功响应、显式响应、status wrapper、无 body 成功状态码和默认错误响应 - 可复用的 component schemas,并支持递归 `$ref` +- 生成后的 operation 过滤和未使用 component 裁剪 - 通过 `openapi.RegisterFormatter` 覆盖自定义类型 schema 支持的 validation tags 包括 `required`、`email`、`url`、`uri`、`uuid`、`uuid4`、`min`、`max`、`gte`、`lte`、`gt`、`lt`、`len`、`oneof` 和 `alphanum`。 @@ -385,4 +427,4 @@ Manifest 模式下,先刷新业务应用负责的 manifest,再生成 OpenAPI ## 当前限制 -当前实现有意不生成 DomainEngine 专用的多 host specs、自定义 schema 命名覆盖,也不支持直接从 YAML 配置为 operation 或 group 分配 tags。简单 operation metadata 可使用 handler 注释里的 `openapi:` 块;需要 Go value 时请使用 `metadataHook`。 +当前实现有意不生成 DomainEngine 专用的多 host specs,也不提供自定义 schema 命名覆盖。CLI 过滤目前支持 scalar extension equality 和 component 裁剪;如果需要按 path、method、operation ID、tags 或 deprecated 等条件过滤,请使用 Go filter API。简单 operation metadata 可使用 handler 注释里的 `openapi:` 块;需要 Go value 时请使用 `metadataHook`。 diff --git a/cmd/fox-openapi/main.go b/cmd/fox-openapi/main.go index c975ffb..c80debe 100644 --- a/cmd/fox-openapi/main.go +++ b/cmd/fox-openapi/main.go @@ -281,6 +281,7 @@ type commonOptions struct { overrides *cli.Overrides sources repeatedFlag servers repeatedFlag + filters repeatedFlag } func newCommonOptions() *commonOptions { @@ -292,6 +293,8 @@ func configFromOptions(opts *commonOptions) (cli.Config, error) { opts.overrides.SourcesSet = opts.sources.set opts.overrides.Servers = opts.servers.values opts.overrides.ServersSet = opts.servers.set + opts.overrides.Filters = opts.filters.values + opts.overrides.FiltersSet = opts.filters.set return cli.LoadConfig(*opts.overrides) } @@ -305,11 +308,13 @@ func bindCommonFlags(flags *pflag.FlagSet, opts *commonOptions) { flags.StringVar(&o.InfoVersion, "version", "", "OpenAPI info version") flags.Var(&opts.servers, "server", "OpenAPI server URL") flags.Var(&opts.sources, "source", "source path") + flags.Var(&opts.filters, "filter", "operation filter expression, e.g. x-public != false") 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 (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.BoolVar(&o.PruneUnusedComponents, "prune-unused-components", false, "remove components no longer referenced after filtering") 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") @@ -419,6 +424,10 @@ func markOverride(o *cli.Overrides, name string) { o.EntryConfigPathSet = true case "route-manifest": o.RouteManifestSet = true + case "filter": + o.FiltersSet = true + case "prune-unused-components": + o.PruneUnusedComponentsSet = true case "workdir": o.WorkdirSet = true case "keep-driver": diff --git a/cmd/fox-openapi/main_test.go b/cmd/fox-openapi/main_test.go index 30886d1..29bcb12 100644 --- a/cmd/fox-openapi/main_test.go +++ b/cmd/fox-openapi/main_test.go @@ -192,6 +192,17 @@ func TestRootCommandAcceptsGenerateFlags(t *testing.T) { } } +func TestGenerateCommandAcceptsFilterFlags(t *testing.T) { + cmd := newGenerateCommand() + if err := cmd.ParseFlags([]string{ + "--filter", "x-public != false", + "--filter", "x-product = sandbox", + "--prune-unused-components", + }); err != nil { + t.Fatalf("parse filter flags: %v", err) + } +} + func TestAdvancedGenerateFlagsAreHiddenButUsable(t *testing.T) { cmd := newGenerateCommand() for _, name := range []string{"source", "include-test-files", "metadata-hook", "entry-config-loader", "entry-config-path", "keep-driver", "verbose", "format"} { diff --git a/filter.go b/filter.go new file mode 100644 index 0000000..aae50b9 --- /dev/null +++ b/filter.go @@ -0,0 +1,559 @@ +package openapi + +import ( + "encoding/json" + "fmt" + "reflect" + "strconv" + "strings" + + "github.com/getkin/kin-openapi/openapi3" +) + +// Filter mutates a generated OpenAPI document. +type Filter func(*openapi3.T) error + +// OperationContext describes an operation while filtering. +type OperationContext struct { + OperationID string + Path string + Method string + PathItem *openapi3.PathItem + Operation *openapi3.Operation +} + +// Extension returns an operation extension value. +func (o OperationContext) Extension(name string) (any, bool) { + if o.Operation == nil || o.Operation.Extensions == nil { + return nil, false + } + value, ok := o.Operation.Extensions[name] + return value, ok +} + +// ExtensionBoolDefault returns a boolean extension value or fallback when the +// extension is absent or not a boolean. +func (o OperationContext) ExtensionBoolDefault(name string, fallback bool) bool { + value, ok := o.Extension(name) + if !ok { + return fallback + } + boolValue, ok := value.(bool) + if !ok { + return fallback + } + return boolValue +} + +// FilterOperations removes operations for which keep returns false. Paths with +// no remaining operations are removed. +func FilterOperations(keep func(OperationContext) bool) Filter { + return func(spec *openapi3.T) error { + if spec == nil || spec.Paths == nil || keep == nil { + return nil + } + for path, item := range spec.Paths.Map() { + if item == nil { + continue + } + ops := item.Operations() + remaining := len(ops) + for method, operation := range ops { + ctx := OperationContext{ + OperationID: operation.OperationID, + Path: path, + Method: method, + PathItem: item, + Operation: operation, + } + if !keep(ctx) { + item.SetOperation(method, nil) + remaining-- + } + } + if remaining == 0 { + spec.Paths.Delete(path) + } + } + return nil + } +} + +// ExcludeOperationsWithExtensionValue removes operations whose extension equals +// the provided scalar value. +func ExcludeOperationsWithExtensionValue(extension string, value any) Filter { + return FilterOperations(func(op OperationContext) bool { + if op.Operation == nil || op.Operation.Extensions == nil { + return true + } + got, ok := op.Operation.Extensions[extension] + if !ok { + return true + } + if scalarEqual(got, value) { + return false + } + return true + }) +} + +// StripOperationExtension removes an extension from all retained operations. +func StripOperationExtension(extension string) Filter { + return FilterOperations(func(op OperationContext) bool { + if op.Operation == nil || op.Operation.Extensions == nil { + return true + } + delete(op.Operation.Extensions, extension) + if len(op.Operation.Extensions) == 0 { + op.Operation.Extensions = nil + } + return true + }) +} + +// FilterOperationExpression builds an operation filter from a small expression +// language: "field = value", "field == value", or "field != value". Field names +// currently address operation extensions such as x-public or x-product. +func FilterOperationExpression(expression string) (Filter, error) { + condition, err := parseOperationFilterExpressionGroup(expression) + if err != nil { + return nil, err + } + return FilterOperations(func(op OperationContext) bool { + return condition.match(op) + }), nil +} + +// PruneUnusedComponents removes components that are no longer reachable from +// paths, operations, top-level security requirements, or other reachable +// components. +func PruneUnusedComponents() Filter { + return func(spec *openapi3.T) error { + if spec == nil || spec.Components == nil { + return nil + } + reachable, err := reachableComponentRefs(spec) + if err != nil { + return err + } + pruneComponentMaps(spec.Components, reachable) + return nil + } +} + +// ApplyFilters applies filters in order. +func ApplyFilters(spec *openapi3.T, filters ...Filter) error { + for _, filter := range filters { + if filter == nil { + continue + } + if err := filter(spec); err != nil { + return err + } + } + return nil +} + +type operationFilterCondition struct { + field string + op string + value any +} + +type operationFilterConditionGroup []operationFilterCondition + +func parseOperationFilterExpressionGroup(expression string) (operationFilterConditionGroup, error) { + parts, err := splitFilterAlternatives(expression) + if err != nil { + return nil, err + } + conditions := make(operationFilterConditionGroup, 0, len(parts)) + for _, part := range parts { + condition, err := parseOperationFilterExpression(part) + if err != nil { + return nil, err + } + conditions = append(conditions, condition) + } + return conditions, nil +} + +func splitFilterAlternatives(expression string) ([]string, error) { + var parts []string + start := 0 + var quote byte + for i := 0; i < len(expression); i++ { + c := expression[i] + if quote != 0 { + if c == '\\' { + i++ + continue + } + if c == quote { + quote = 0 + } + continue + } + if c == '\'' || c == '"' { + quote = c + continue + } + if c == '|' && i+1 < len(expression) && expression[i+1] == '|' { + parts = append(parts, strings.TrimSpace(expression[start:i])) + i++ + start = i + 1 + } + } + if quote != 0 { + return nil, fmt.Errorf("filter %q: unterminated quoted value", expression) + } + parts = append(parts, strings.TrimSpace(expression[start:])) + return parts, nil +} + +func parseOperationFilterExpression(expression string) (operationFilterCondition, error) { + expression = strings.TrimSpace(expression) + for _, op := range []string{"!=", "==", "="} { + left, right, ok := strings.Cut(expression, op) + if !ok { + continue + } + field := strings.TrimSpace(left) + if field == "" { + return operationFilterCondition{}, fmt.Errorf("filter %q: missing field", expression) + } + valueText := strings.TrimSpace(right) + if valueText == "" { + return operationFilterCondition{}, fmt.Errorf("filter %q: missing value", expression) + } + return operationFilterCondition{ + field: field, + op: op, + value: parseFilterLiteral(valueText), + }, nil + } + return operationFilterCondition{}, fmt.Errorf("filter %q: expected FIELD = VALUE or FIELD != VALUE", expression) +} + +func parseFilterLiteral(value string) any { + value = strings.TrimSpace(value) + switch strings.ToLower(value) { + case "true": + return true + case "false": + return false + } + if unquoted, ok := stripQuotes(value); ok { + return unquoted + } + if number, err := strconv.ParseFloat(value, 64); err == nil { + return number + } + return value +} + +func stripQuotes(value string) (string, bool) { + if len(value) < 2 { + return "", false + } + first, last := value[0], value[len(value)-1] + if first == '"' && last == '"' { + if unquoted, err := strconv.Unquote(value); err == nil { + return unquoted, true + } + } + if first == '\'' && last == '\'' { + return unquoteSingleQuoted(value[1 : len(value)-1]), true + } + return "", false +} + +func unquoteSingleQuoted(value string) string { + var out strings.Builder + out.Grow(len(value)) + for i := 0; i < len(value); i++ { + if value[i] == '\\' && i+1 < len(value) { + next := value[i+1] + if next == '\'' || next == '\\' { + out.WriteByte(next) + i++ + continue + } + } + out.WriteByte(value[i]) + } + return out.String() +} + +func (g operationFilterConditionGroup) match(op OperationContext) bool { + for _, condition := range g { + if condition.match(op) { + return true + } + } + return false +} + +func (c operationFilterCondition) match(op OperationContext) bool { + got, ok := op.Extension(c.field) + equal := ok && scalarEqual(got, c.value) + switch c.op { + case "=", "==": + return equal + case "!=": + return !equal + default: + return false + } +} + +func scalarEqual(left, right any) bool { + if reflect.TypeOf(left) == reflect.TypeOf(right) { + return reflect.DeepEqual(left, right) + } + leftNumber, leftOK := numericScalar(left) + rightNumber, rightOK := numericScalar(right) + if leftOK && rightOK { + return leftNumber == rightNumber + } + return false +} + +func numericScalar(value any) (float64, bool) { + switch typed := value.(type) { + case int: + return float64(typed), true + case int8: + return float64(typed), true + case int16: + return float64(typed), true + case int32: + return float64(typed), true + case int64: + return float64(typed), true + case uint: + return float64(typed), true + case uint8: + return float64(typed), true + case uint16: + return float64(typed), true + case uint32: + return float64(typed), true + case uint64: + return float64(typed), true + case float32: + return float64(typed), true + case float64: + return typed, true + case json.Number: + parsed, err := typed.Float64() + return parsed, err == nil + default: + return 0, false + } +} + +func reachableComponentRefs(spec *openapi3.T) (map[string]struct{}, error) { + reachable := map[string]struct{}{} + queue := []string{} + addRef := func(ref string) { + rootRef, ok := componentRootRef(ref) + if !ok { + return + } + ref = rootRef + if _, ok := reachable[ref]; ok { + return + } + reachable[ref] = struct{}{} + queue = append(queue, ref) + } + + root, err := jsonObject(spec) + if err != nil { + return nil, fmt.Errorf("inspect OpenAPI document refs: %w", err) + } + collectRefsOutsideComponents(root, addRef) + collectSecurityRequirementRefs(spec.Security, addRef) + if spec.Paths != nil { + for _, item := range spec.Paths.Map() { + if item == nil { + continue + } + for _, operation := range item.Operations() { + if operation != nil && operation.Security != nil { + collectSecurityRequirementRefs(*operation.Security, addRef) + } + } + } + } + + for len(queue) > 0 { + ref := queue[0] + queue = queue[1:] + component, ok := componentJSONValue(root, ref) + if !ok { + continue + } + collectRefs(component, addRef) + } + return reachable, nil +} + +func jsonObject(value any) (any, error) { + data, err := json.Marshal(value) + if err != nil { + return nil, err + } + var out any + if err := json.Unmarshal(data, &out); err != nil { + return nil, err + } + return out, nil +} + +func collectRefsOutsideComponents(value any, addRef func(string)) { + object, ok := value.(map[string]any) + if !ok { + collectRefs(value, addRef) + return + } + for key, child := range object { + if key == "components" { + continue + } + collectRefs(child, addRef) + } +} + +func collectRefs(value any, addRef func(string)) { + switch typed := value.(type) { + case map[string]any: + for key, child := range typed { + switch key { + case "$ref", "operationRef": + if ref, ok := child.(string); ok && strings.HasPrefix(ref, "#/components/") { + addRef(ref) + } + case "mapping": + if isDiscriminatorObject(typed) { + collectComponentRefStrings(child, addRef) + } + } + collectRefs(child, addRef) + } + case []any: + for _, child := range typed { + collectRefs(child, addRef) + } + } +} + +func isDiscriminatorObject(value map[string]any) bool { + propertyName, ok := value["propertyName"].(string) + return ok && propertyName != "" +} + +func collectComponentRefStrings(value any, addRef func(string)) { + switch typed := value.(type) { + case string: + if strings.HasPrefix(typed, "#/components/") { + addRef(typed) + } else if typed != "" && !strings.Contains(typed, "://") { + addRef("#/components/schemas/" + escapeJSONPointer(typed)) + } + case map[string]any: + for _, child := range typed { + collectComponentRefStrings(child, addRef) + } + case []any: + for _, child := range typed { + collectComponentRefStrings(child, addRef) + } + } +} + +func collectSecurityRequirementRefs(requirements openapi3.SecurityRequirements, addRef func(string)) { + for _, requirement := range requirements { + for name := range requirement { + addRef("#/components/securitySchemes/" + escapeJSONPointer(name)) + } + } +} + +func componentJSONValue(root any, ref string) (any, bool) { + group, name, ok := splitComponentRef(ref) + if !ok { + return nil, false + } + object, ok := root.(map[string]any) + if !ok { + return nil, false + } + components, ok := object["components"].(map[string]any) + if !ok { + return nil, false + } + groupValues, ok := components[group].(map[string]any) + if !ok { + return nil, false + } + value, ok := groupValues[name] + return value, ok +} + +func pruneComponentMaps(components *openapi3.Components, reachable map[string]struct{}) { + pruneByGroup(components.Schemas, "schemas", reachable) + pruneByGroup(components.Responses, "responses", reachable) + pruneByGroup(components.Parameters, "parameters", reachable) + pruneByGroup(components.RequestBodies, "requestBodies", reachable) + pruneByGroup(components.Headers, "headers", reachable) + pruneByGroup(components.SecuritySchemes, "securitySchemes", reachable) + pruneByGroup(components.Examples, "examples", reachable) + pruneByGroup(components.Links, "links", reachable) + pruneByGroup(components.Callbacks, "callbacks", reachable) +} + +func pruneByGroup[V any](m map[string]V, group string, reachable map[string]struct{}) { + prefix := "#/components/" + group + "/" + for name := range m { + if _, ok := reachable[prefix+escapeJSONPointer(name)]; !ok { + delete(m, name) + } + } +} + +func splitComponentRef(ref string) (string, string, bool) { + const prefix = "#/components/" + if !strings.HasPrefix(ref, prefix) { + return "", "", false + } + remainder := strings.TrimPrefix(ref, prefix) + group, rest, ok := strings.Cut(remainder, "/") + if !ok || group == "" || rest == "" { + return "", "", false + } + name, _, _ := strings.Cut(rest, "/") + if name == "" { + return "", "", false + } + return unescapeJSONPointer(group), unescapeJSONPointer(name), true +} + +func componentRootRef(ref string) (string, bool) { + group, name, ok := splitComponentRef(ref) + if !ok { + return "", false + } + return "#/components/" + escapeJSONPointer(group) + "/" + escapeJSONPointer(name), true +} + +func escapeJSONPointer(value string) string { + value = strings.ReplaceAll(value, "~", "~0") + return strings.ReplaceAll(value, "/", "~1") +} + +func unescapeJSONPointer(value string) string { + value = strings.ReplaceAll(value, "~1", "/") + return strings.ReplaceAll(value, "~0", "~") +} diff --git a/filter_test.go b/filter_test.go new file mode 100644 index 0000000..d7b8b61 --- /dev/null +++ b/filter_test.go @@ -0,0 +1,416 @@ +package openapi_test + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/stretchr/testify/require" + + "github.com/fox-gonic/openapi" +) + +func TestFilterOperationsKeepsMatchingOperationsAndPrunesEmptyPaths(t *testing.T) { + spec := &openapi3.T{Paths: openapi3.NewPaths()} + spec.Paths.Set("/public", &openapi3.PathItem{Get: &openapi3.Operation{OperationID: "public"}}) + spec.Paths.Set("/internal", &openapi3.PathItem{Get: &openapi3.Operation{OperationID: "internal"}}) + + err := openapi.ApplyFilters(spec, openapi.FilterOperations(func(op openapi.OperationContext) bool { + return op.OperationID == "public" + })) + + require.NoError(t, err) + require.NotNil(t, spec.Paths.Value("/public").Get) + require.Nil(t, spec.Paths.Value("/internal")) +} + +func TestExcludeOperationsWithExtensionValueHandlesNonComparableValues(t *testing.T) { + spec := &openapi3.T{Paths: openapi3.NewPaths()} + spec.Paths.Set("/internal", &openapi3.PathItem{Get: &openapi3.Operation{ + OperationID: "internal", + Extensions: map[string]any{"x-filter": map[string]any{"scope": "internal"}}, + }}) + spec.Paths.Set("/public", &openapi3.PathItem{Get: &openapi3.Operation{ + OperationID: "public", + Extensions: map[string]any{"x-filter": map[string]any{"scope": "public"}}, + }}) + + err := openapi.ApplyFilters(spec, openapi.ExcludeOperationsWithExtensionValue( + "x-filter", + map[string]any{"scope": "internal"}, + )) + + require.NoError(t, err) + require.Nil(t, spec.Paths.Value("/internal")) + require.NotNil(t, spec.Paths.Value("/public")) + require.Contains(t, spec.Paths.Value("/public").Get.Extensions, "x-filter") +} + +func TestExcludeOperationsWithExtensionValueHandlesNumericScalarTypes(t *testing.T) { + spec := &openapi3.T{Paths: openapi3.NewPaths()} + spec.Paths.Set("/created", &openapi3.PathItem{Get: &openapi3.Operation{ + OperationID: "created", + Extensions: map[string]any{"x-status": json.Number("201")}, + }}) + spec.Paths.Set("/accepted", &openapi3.PathItem{Get: &openapi3.Operation{ + OperationID: "accepted", + Extensions: map[string]any{"x-status": json.Number("202")}, + }}) + + err := openapi.ApplyFilters(spec, openapi.ExcludeOperationsWithExtensionValue("x-status", int64(201))) + + require.NoError(t, err) + require.Nil(t, spec.Paths.Value("/created")) + require.NotNil(t, spec.Paths.Value("/accepted")) +} + +func TestStripOperationExtensionRemovesExtensionFromRetainedOperations(t *testing.T) { + spec := &openapi3.T{Paths: openapi3.NewPaths()} + spec.Paths.Set("/public", &openapi3.PathItem{Get: &openapi3.Operation{ + OperationID: "public", + Extensions: map[string]any{"x-filter": "public", "x-keep": true}, + }}) + + err := openapi.ApplyFilters(spec, openapi.StripOperationExtension("x-filter")) + + require.NoError(t, err) + require.NotContains(t, spec.Paths.Value("/public").Get.Extensions, "x-filter") + require.Contains(t, spec.Paths.Value("/public").Get.Extensions, "x-keep") +} + +func TestPruneUnusedComponentsRemovesFilteredOperationRefs(t *testing.T) { + spec := &openapi3.T{ + Paths: openapi3.NewPaths(), + Components: &openapi3.Components{ + Schemas: openapi3.Schemas{ + "Public": openapi3.NewSchemaRef("", openapi3.NewObjectSchema()), + "Internal": openapi3.NewSchemaRef("", openapi3.NewObjectSchema()), + }, + Responses: openapi3.ResponseBodies{ + "PublicError": &openapi3.ResponseRef{Value: openapi3.NewResponse().WithDescription("public")}, + "InternalError": &openapi3.ResponseRef{Value: openapi3.NewResponse().WithDescription("internal")}, + }, + SecuritySchemes: openapi3.SecuritySchemes{ + "BearerAuth": &openapi3.SecuritySchemeRef{Value: openapi3.NewSecurityScheme().WithType("http").WithScheme("bearer")}, + "Internal": &openapi3.SecuritySchemeRef{Value: openapi3.NewSecurityScheme().WithType("apiKey").WithName("X-Internal").WithIn("header")}, + }, + }, + } + spec.Paths.Set("/public", &openapi3.PathItem{Get: &openapi3.Operation{ + OperationID: "public", + Responses: openapi3.NewResponses( + openapi3.WithStatus(http.StatusOK, &openapi3.ResponseRef{Value: openapi3.NewResponse(). + WithDescription("OK"). + WithJSONSchemaRef(&openapi3.SchemaRef{Ref: "#/components/schemas/Public"})}), + ), + Security: &openapi3.SecurityRequirements{{"BearerAuth": []string{}}}, + }}) + spec.Paths.Value("/public").Get.Responses.Set("default", &openapi3.ResponseRef{Ref: "#/components/responses/PublicError"}) + spec.Paths.Set("/internal", &openapi3.PathItem{Get: &openapi3.Operation{ + OperationID: "internal", + Responses: openapi3.NewResponses(openapi3.WithStatus(http.StatusOK, &openapi3.ResponseRef{Value: openapi3.NewResponse(). + WithDescription("OK"). + WithJSONSchemaRef(&openapi3.SchemaRef{Ref: "#/components/schemas/Internal"})})), + Security: &openapi3.SecurityRequirements{{"Internal": []string{}}}, + }}) + + err := openapi.ApplyFilters(spec, + openapi.FilterOperations(func(op openapi.OperationContext) bool { + return op.OperationID == "public" + }), + openapi.PruneUnusedComponents(), + ) + + require.NoError(t, err) + require.Contains(t, spec.Components.Schemas, "Public") + require.NotContains(t, spec.Components.Schemas, "Internal") + require.Contains(t, spec.Components.Responses, "PublicError") + require.NotContains(t, spec.Components.Responses, "InternalError") + require.Contains(t, spec.Components.SecuritySchemes, "BearerAuth") + require.NotContains(t, spec.Components.SecuritySchemes, "Internal") +} + +func TestPruneUnusedComponentsKeepsComponentReachedBySubpathRef(t *testing.T) { + spec := &openapi3.T{ + Paths: openapi3.NewPaths(), + Components: &openapi3.Components{ + Schemas: openapi3.Schemas{ + "User": openapi3.NewSchemaRef("", openapi3.NewObjectSchema().WithProperty("name", openapi3.NewStringSchema())), + "Unused": openapi3.NewSchemaRef("", openapi3.NewObjectSchema()), + }, + }, + } + spec.Paths.Set("/user-name", &openapi3.PathItem{Get: &openapi3.Operation{ + OperationID: "userName", + Responses: openapi3.NewResponses(openapi3.WithStatus(http.StatusOK, &openapi3.ResponseRef{Value: openapi3.NewResponse(). + WithDescription("OK"). + WithJSONSchemaRef(&openapi3.SchemaRef{Ref: "#/components/schemas/User/properties/name"})})), + }}) + + err := openapi.ApplyFilters(spec, openapi.PruneUnusedComponents()) + + require.NoError(t, err) + require.Contains(t, spec.Components.Schemas, "User") + require.NotContains(t, spec.Components.Schemas, "Unused") +} + +func TestPruneUnusedComponentsKeepsDiscriminatorMappingRefs(t *testing.T) { + pet := openapi3.NewObjectSchema() + pet.Discriminator = &openapi3.Discriminator{ + PropertyName: "kind", + Mapping: openapi3.StringMap[openapi3.MappingRef]{ + "cat": {Ref: "#/components/schemas/Cat"}, + }, + } + spec := &openapi3.T{ + Paths: openapi3.NewPaths(), + Components: &openapi3.Components{ + Schemas: openapi3.Schemas{ + "Pet": openapi3.NewSchemaRef("", pet), + "Cat": openapi3.NewSchemaRef("", openapi3.NewObjectSchema()), + "Unused": openapi3.NewSchemaRef("", openapi3.NewObjectSchema()), + }, + }, + } + spec.Paths.Set("/pets", &openapi3.PathItem{Get: &openapi3.Operation{ + OperationID: "pets", + Responses: openapi3.NewResponses(openapi3.WithStatus(http.StatusOK, &openapi3.ResponseRef{Value: openapi3.NewResponse(). + WithDescription("OK"). + WithJSONSchemaRef(&openapi3.SchemaRef{Ref: "#/components/schemas/Pet"})})), + }}) + + err := openapi.ApplyFilters(spec, openapi.PruneUnusedComponents()) + + require.NoError(t, err) + require.Contains(t, spec.Components.Schemas, "Pet") + require.Contains(t, spec.Components.Schemas, "Cat") + require.NotContains(t, spec.Components.Schemas, "Unused") +} + +func TestPruneUnusedComponentsKeepsDiscriminatorMappingSchemaNames(t *testing.T) { + pet := openapi3.NewObjectSchema() + pet.Discriminator = &openapi3.Discriminator{ + PropertyName: "kind", + Mapping: openapi3.StringMap[openapi3.MappingRef]{ + "cat": {Ref: "Cat"}, + }, + } + spec := &openapi3.T{ + Paths: openapi3.NewPaths(), + Components: &openapi3.Components{ + Schemas: openapi3.Schemas{ + "Pet": openapi3.NewSchemaRef("", pet), + "Cat": openapi3.NewSchemaRef("", openapi3.NewObjectSchema()), + "Unused": openapi3.NewSchemaRef("", openapi3.NewObjectSchema()), + }, + }, + } + spec.Paths.Set("/pets", &openapi3.PathItem{Get: &openapi3.Operation{ + OperationID: "pets", + Responses: openapi3.NewResponses(openapi3.WithStatus(http.StatusOK, &openapi3.ResponseRef{Value: openapi3.NewResponse(). + WithDescription("OK"). + WithJSONSchemaRef(&openapi3.SchemaRef{Ref: "#/components/schemas/Pet"})})), + }}) + + err := openapi.ApplyFilters(spec, openapi.PruneUnusedComponents()) + + require.NoError(t, err) + require.Contains(t, spec.Components.Schemas, "Pet") + require.Contains(t, spec.Components.Schemas, "Cat") + require.NotContains(t, spec.Components.Schemas, "Unused") +} + +func TestPruneUnusedComponentsIgnoresNonDiscriminatorMappingValues(t *testing.T) { + container := openapi3.NewObjectSchema() + container.Extensions = map[string]any{ + "mapping": map[string]any{"shadow": "Shadow"}, + } + spec := &openapi3.T{ + Paths: openapi3.NewPaths(), + Components: &openapi3.Components{ + Schemas: openapi3.Schemas{ + "Container": openapi3.NewSchemaRef("", container), + "Shadow": openapi3.NewSchemaRef("", openapi3.NewObjectSchema()), + }, + }, + } + spec.Paths.Set("/containers", &openapi3.PathItem{Get: &openapi3.Operation{ + OperationID: "containers", + Responses: openapi3.NewResponses(openapi3.WithStatus(http.StatusOK, &openapi3.ResponseRef{Value: openapi3.NewResponse(). + WithDescription("OK"). + WithJSONSchemaRef(&openapi3.SchemaRef{Ref: "#/components/schemas/Container"})})), + }}) + + err := openapi.ApplyFilters(spec, openapi.PruneUnusedComponents()) + + require.NoError(t, err) + require.Contains(t, spec.Components.Schemas, "Container") + require.NotContains(t, spec.Components.Schemas, "Shadow") +} + +func TestPruneUnusedComponentsKeepsOperationRefComponentRefs(t *testing.T) { + spec := &openapi3.T{ + Paths: openapi3.NewPaths(), + Components: &openapi3.Components{ + Links: openapi3.Links{ + "UserLookup": &openapi3.LinkRef{Value: &openapi3.Link{OperationRef: "#/components/schemas/User"}}, + "UnusedLink": &openapi3.LinkRef{Value: &openapi3.Link{OperationID: "unused"}}, + }, + Schemas: openapi3.Schemas{ + "User": openapi3.NewSchemaRef("", openapi3.NewObjectSchema()), + "Unused": openapi3.NewSchemaRef("", openapi3.NewObjectSchema()), + }, + }, + } + response := openapi3.NewResponse().WithDescription("OK") + response.Links = openapi3.Links{"user": &openapi3.LinkRef{Ref: "#/components/links/UserLookup"}} + spec.Paths.Set("/users", &openapi3.PathItem{Get: &openapi3.Operation{ + OperationID: "users", + Responses: openapi3.NewResponses(openapi3.WithStatus(http.StatusOK, &openapi3.ResponseRef{Value: response})), + }}) + + err := openapi.ApplyFilters(spec, openapi.PruneUnusedComponents()) + + require.NoError(t, err) + require.Contains(t, spec.Components.Links, "UserLookup") + require.NotContains(t, spec.Components.Links, "UnusedLink") + require.Contains(t, spec.Components.Schemas, "User") + require.NotContains(t, spec.Components.Schemas, "Unused") +} + +func TestOperationExtensionBoolDefault(t *testing.T) { + op := openapi.OperationContext{Operation: &openapi3.Operation{Extensions: map[string]any{"x-public": false}}} + + require.False(t, op.ExtensionBoolDefault("x-public", true)) + require.True(t, op.ExtensionBoolDefault("x-missing", true)) +} + +func TestFilterOperationExpressionMatchesExtensionConditions(t *testing.T) { + spec := &openapi3.T{Paths: openapi3.NewPaths()} + spec.Paths.Set("/public-sandbox", &openapi3.PathItem{Get: &openapi3.Operation{ + OperationID: "publicSandbox", + Extensions: map[string]any{"x-public": true, "x-product": "sandbox"}, + }}) + spec.Paths.Set("/internal-sandbox", &openapi3.PathItem{Get: &openapi3.Operation{ + OperationID: "internalSandbox", + Extensions: map[string]any{"x-public": false, "x-product": "sandbox"}, + }}) + spec.Paths.Set("/public-account", &openapi3.PathItem{Get: &openapi3.Operation{ + OperationID: "publicAccount", + Extensions: map[string]any{"x-public": true, "x-product": "account"}, + }}) + + public, err := openapi.FilterOperationExpression("x-public != false") + require.NoError(t, err) + sandbox, err := openapi.FilterOperationExpression("x-product = sandbox") + require.NoError(t, err) + + err = openapi.ApplyFilters(spec, public, sandbox) + + require.NoError(t, err) + require.NotNil(t, spec.Paths.Value("/public-sandbox")) + require.Nil(t, spec.Paths.Value("/internal-sandbox")) + require.Nil(t, spec.Paths.Value("/public-account")) +} + +func TestFilterOperationExpressionMatchesNumericLiteral(t *testing.T) { + spec := &openapi3.T{Paths: openapi3.NewPaths()} + spec.Paths.Set("/created", &openapi3.PathItem{Get: &openapi3.Operation{ + OperationID: "created", + Extensions: map[string]any{"x-status": json.Number("201")}, + }}) + spec.Paths.Set("/accepted", &openapi3.PathItem{Get: &openapi3.Operation{ + OperationID: "accepted", + Extensions: map[string]any{"x-status": json.Number("202")}, + }}) + + statusFilter, err := openapi.FilterOperationExpression("x-status = 201") + require.NoError(t, err) + + err = openapi.ApplyFilters(spec, statusFilter) + + require.NoError(t, err) + require.NotNil(t, spec.Paths.Value("/created")) + require.Nil(t, spec.Paths.Value("/accepted")) +} + +func TestFilterOperationExpressionSupportsOrConditions(t *testing.T) { + spec := &openapi3.T{Paths: openapi3.NewPaths()} + spec.Paths.Set("/sandbox", &openapi3.PathItem{Get: &openapi3.Operation{ + OperationID: "sandbox", + Extensions: map[string]any{"x-product": "sandbox"}, + }}) + spec.Paths.Set("/account", &openapi3.PathItem{Get: &openapi3.Operation{ + OperationID: "account", + Extensions: map[string]any{"x-product": "account"}, + }}) + spec.Paths.Set("/admin", &openapi3.PathItem{Get: &openapi3.Operation{ + OperationID: "admin", + Extensions: map[string]any{"x-product": "admin"}, + }}) + + productFilter, err := openapi.FilterOperationExpression("x-product = sandbox || x-product = account") + require.NoError(t, err) + + err = openapi.ApplyFilters(spec, productFilter) + + require.NoError(t, err) + require.NotNil(t, spec.Paths.Value("/sandbox")) + require.NotNil(t, spec.Paths.Value("/account")) + require.Nil(t, spec.Paths.Value("/admin")) +} + +func TestFilterOperationExpressionKeepsQuotedOrLiteralTogether(t *testing.T) { + spec := &openapi3.T{Paths: openapi3.NewPaths()} + spec.Paths.Set("/compound", &openapi3.PathItem{Get: &openapi3.Operation{ + OperationID: "compound", + Extensions: map[string]any{"x-product": "A || B"}, + }}) + spec.Paths.Set("/simple", &openapi3.PathItem{Get: &openapi3.Operation{ + OperationID: "simple", + Extensions: map[string]any{"x-product": "A"}, + }}) + + productFilter, err := openapi.FilterOperationExpression(`x-product = "A || B"`) + require.NoError(t, err) + + err = openapi.ApplyFilters(spec, productFilter) + + require.NoError(t, err) + require.NotNil(t, spec.Paths.Value("/compound")) + require.Nil(t, spec.Paths.Value("/simple")) +} + +func TestFilterOperationExpressionSupportsEscapedSingleQuote(t *testing.T) { + spec := &openapi3.T{Paths: openapi3.NewPaths()} + spec.Paths.Set("/publisher", &openapi3.PathItem{Get: &openapi3.Operation{ + OperationID: "publisher", + Extensions: map[string]any{"x-name": "O'Reilly"}, + }}) + spec.Paths.Set("/other", &openapi3.PathItem{Get: &openapi3.Operation{ + OperationID: "other", + Extensions: map[string]any{"x-name": "Other"}, + }}) + + nameFilter, err := openapi.FilterOperationExpression(`x-name = 'O\'Reilly'`) + require.NoError(t, err) + + err = openapi.ApplyFilters(spec, nameFilter) + + require.NoError(t, err) + require.NotNil(t, spec.Paths.Value("/publisher")) + require.Nil(t, spec.Paths.Value("/other")) +} + +func TestFilterOperationExpressionRejectsUnsupportedExpression(t *testing.T) { + _, err := openapi.FilterOperationExpression("x-public > false") + + require.Error(t, err) +} + +func TestFilterOperationExpressionRejectsUnterminatedQuotedValue(t *testing.T) { + _, err := openapi.FilterOperationExpression(`x-product = "sandbox`) + + require.Error(t, err) +} diff --git a/internal/cli/config.go b/internal/cli/config.go index e15b2a1..dd50454 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -34,12 +34,14 @@ type Config struct { Servers []ServerConfig `yaml:"servers"` Tags []TagConfig `yaml:"tags"` 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"` + Filters []string `yaml:"filters"` + PruneUnusedComponents bool `yaml:"pruneUnusedComponents"` + MetadataHook string `yaml:"metadataHook"` + EntryConfig EntryConfig `yaml:"entryConfig"` + RouteManifest string `yaml:"routeManifest"` + Workdir string `yaml:"workdir"` + KeepDriver bool `yaml:"keepDriver"` + Verbose bool `yaml:"verbose"` // EntryAutoDiscovered is true when the entry was filled in by // DiscoverEntry rather than the config file or CLI flags. CLI commands // use this to surface "entry: ..." back to the user so the auto-pick is @@ -110,38 +112,42 @@ type OAuthFlow struct { } type Overrides struct { - ConfigPath string - ConfigExplicit bool - Entry string - EntrySet bool - Out string - OutSet bool - Format string - FormatSet bool - InfoTitle string - InfoTitleSet bool - InfoVersion string - InfoVersionSet bool - Servers []string - ServersSet bool - Sources []string - SourcesSet bool - IncludeTestFiles bool - IncludeTestFilesSet bool - MetadataHook string - MetadataHookSet bool - EntryConfigLoader string - EntryConfigLoaderSet bool - EntryConfigPath string - EntryConfigPathSet bool - RouteManifest string - RouteManifestSet bool - Workdir string - WorkdirSet bool - KeepDriver bool - KeepDriverSet bool - Verbose bool - VerboseSet bool + ConfigPath string + ConfigExplicit bool + Entry string + EntrySet bool + Out string + OutSet bool + Format string + FormatSet bool + InfoTitle string + InfoTitleSet bool + InfoVersion string + InfoVersionSet bool + Servers []string + ServersSet bool + Sources []string + SourcesSet bool + IncludeTestFiles bool + IncludeTestFilesSet bool + MetadataHook string + MetadataHookSet bool + EntryConfigLoader string + EntryConfigLoaderSet bool + EntryConfigPath string + EntryConfigPathSet bool + RouteManifest string + RouteManifestSet bool + Filters []string + FiltersSet bool + PruneUnusedComponents bool + PruneUnusedComponentsSet bool + Workdir string + WorkdirSet bool + KeepDriver bool + KeepDriverSet bool + Verbose bool + VerboseSet bool // EntryDiscoveryScope is set by the CLI from the optional positional // path argument. It limits where DiscoverEntry searches but does not // affect Sources (comment extraction). @@ -328,6 +334,10 @@ func mergeFromFile(cfg *Config, fileCfg Config, configDir string) { if len(fileCfg.SecuritySchemes) > 0 { cfg.SecuritySchemes = fileCfg.SecuritySchemes } + if len(fileCfg.Filters) > 0 { + cfg.Filters = append([]string(nil), fileCfg.Filters...) + } + cfg.PruneUnusedComponents = fileCfg.PruneUnusedComponents if fileCfg.MetadataHook != "" { cfg.MetadataHook = fileCfg.MetadataHook } @@ -401,6 +411,12 @@ func applyOverrides(cfg *Config, o Overrides, cwd string) { if o.RouteManifestSet { cfg.RouteManifest = resolveRelative(cwd, o.RouteManifest) } + if o.FiltersSet { + cfg.Filters = append([]string(nil), o.Filters...) + } + if o.PruneUnusedComponentsSet { + cfg.PruneUnusedComponents = o.PruneUnusedComponents + } if o.WorkdirSet { cfg.Workdir = resolveRelative(cwd, o.Workdir) } diff --git a/internal/cli/config_test.go b/internal/cli/config_test.go index 62dc59a..6e13bb7 100644 --- a/internal/cli/config_test.go +++ b/internal/cli/config_test.go @@ -19,6 +19,8 @@ info: title: From Config version: 1.2.3 metadataHook: example.com/app/internal/server.ConfigureOpenAPI +filters: + - x-public != false `), 0o644); err != nil { t.Fatal(err) } @@ -34,6 +36,8 @@ metadataHook: example.com/app/internal/server.ConfigureOpenAPI EntrySet: true, MetadataHook: "", MetadataHookSet: true, + Filters: []string{"x-product = sandbox"}, + FiltersSet: true, IncludeTestFiles: true, IncludeTestFilesSet: true, }) @@ -58,6 +62,9 @@ metadataHook: example.com/app/internal/server.ConfigureOpenAPI if cfg.MetadataHook != "" { t.Fatalf("metadataHook override not applied: hook=%q", cfg.MetadataHook) } + if len(cfg.Filters) != 1 || cfg.Filters[0] != "x-product = sandbox" { + t.Fatalf("filters override not applied: %#v", cfg.Filters) + } if !cfg.IncludeTestFiles { t.Fatal("includeTestFiles override not applied") } diff --git a/internal/cli/driver.go b/internal/cli/driver.go index 1e796e9..b9be019 100644 --- a/internal/cli/driver.go +++ b/internal/cli/driver.go @@ -9,6 +9,8 @@ import ( "strconv" "strings" "text/template" + + openapi "github.com/fox-gonic/openapi" ) const driverTemplate = `// Code generated by fox-openapi. DO NOT EDIT. @@ -64,7 +66,11 @@ func main() { opts = append(opts, userhook.{{.Hook.FuncName}}()...) {{- end }} g := openapi.New(engine, opts...) - spec := g.Spec() + spec, err2 := g.SpecErr() + if err2 != nil { + fmt.Fprintf(os.Stderr, "generate spec: %v\n", err2) + os.Exit(1) + } openapi.ApplySpecMetadata(spec, openapi.SpecMetadata{ InfoDescription: {{quote .InfoDescription}}, ServerDescriptions: []string{ @@ -79,11 +85,10 @@ func main() { }, }) var out []byte - var err2 error {{- if eq .Format "json" }} - out, err2 = openapi.MarshalSpecJSON(spec) + out, err2 = g.JSON() {{- else }} - out, err2 = openapi.MarshalSpecYAML(spec) + out, err2 = g.YAML() {{- end }} if err2 != nil { fmt.Fprintf(os.Stderr, "generate spec: %v\n", err2) @@ -97,6 +102,14 @@ func main() { os.Exit(1) } } + +func mustFilter(filter openapi.Filter, err error) openapi.Filter { + if err != nil { + fmt.Fprintf(os.Stderr, "filter expression: %v\n", err) + os.Exit(1) + } + return filter +} ` var driverTpl = template.Must(template.New("driver").Funcs(template.FuncMap{"quote": strconv.Quote}).Parse(driverTemplate)) @@ -164,6 +177,13 @@ func BuildDriverData(cfg Config, entry Entry, hook *Hook, loader *ConfigLoader) for _, name := range sortedSchemeNames(cfg.SecuritySchemes) { snippets = append(snippets, securitySchemeSnippet(name, cfg.SecuritySchemes[name])) } + filterSnippets, err := filterSnippets(cfg) + if err != nil { + return DriverData{}, err + } + if len(filterSnippets) > 0 { + snippets = append(snippets, "openapi.WithFilters("+strings.Join(filterSnippets, ", ")+")") + } serverDescriptions := make([]string, len(cfg.Servers)) for i, server := range cfg.Servers { if server.Description != "" { @@ -195,6 +215,20 @@ func BuildDriverData(cfg Config, entry Entry, hook *Hook, loader *ConfigLoader) return data, nil } +func filterSnippets(cfg Config) ([]string, error) { + var snippets []string + for _, expression := range cfg.Filters { + if _, err := openapi.FilterOperationExpression(expression); err != nil { + return nil, err + } + snippets = append(snippets, fmt.Sprintf("mustFilter(openapi.FilterOperationExpression(%s))", strconv.Quote(expression))) + } + if cfg.PruneUnusedComponents { + snippets = append(snippets, "openapi.PruneUnusedComponents()") + } + return snippets, nil +} + func absoluteSources(workdir string, sources []string) ([]string, error) { result := make([]string, 0, len(sources)) for _, source := range sources { diff --git a/internal/cli/driver_test.go b/internal/cli/driver_test.go index a2e7a1e..7664c74 100644 --- a/internal/cli/driver_test.go +++ b/internal/cli/driver_test.go @@ -34,6 +34,8 @@ func TestWriteDriverRendersMetadataAndAbsoluteSources(t *testing.T) { BearerFormat: "JWT", }, }, + Filters: []string{"x-public != false", "x-product = sandbox"}, + PruneUnusedComponents: true, } driverDir, err := WriteDriver(cfg, Entry{ImportPath: "example.com/app/internal/server", FuncName: "NewEngine"}, &Hook{ImportPath: "example.com/app/internal/server", FuncName: "ConfigureOpenAPI"}, nil) if err != nil { @@ -52,6 +54,9 @@ func TestWriteDriverRendersMetadataAndAbsoluteSources(t *testing.T) { `"prod"`, `openapi.SpecTag{Name: "users"`, `openapi.SecuritySchemeFromConfig("BearerAuth"`, + `openapi.FilterOperationExpression("x-public != false")`, + `openapi.FilterOperationExpression("x-product = sandbox")`, + `openapi.PruneUnusedComponents()`, `opts = append(opts, userhook.ConfigureOpenAPI()...)`, filepath.ToSlash(filepath.Join(dir, "internal/server")), filepath.ToSlash(filepath.Join(dir, "pkg")) + "/...", diff --git a/internal/cli/pipeline.go b/internal/cli/pipeline.go index 0b79729..626e637 100644 --- a/internal/cli/pipeline.go +++ b/internal/cli/pipeline.go @@ -80,13 +80,16 @@ func runManifestPipeline(cfg Config) ([]byte, []string, error) { return nil, nil, err } g := openapi.NewFromRouteManifest(manifest, opts...) - spec := g.Spec() + spec, err := g.SpecErr() + if err != nil { + return nil, nil, fmt.Errorf("generate spec: %w", err) + } openapi.ApplySpecMetadata(spec, specMetadata(cfg)) var out []byte if cfg.Format == FormatJSON { - out, err = openapi.MarshalSpecJSON(spec) + out, err = g.JSON() } else { - out, err = openapi.MarshalSpecYAML(spec) + out, err = g.YAML() } if err != nil { return nil, nil, fmt.Errorf("generate spec: %w", err) @@ -117,9 +120,31 @@ func manifestOptions(cfg Config) ([]openapi.Option, error) { for _, name := range sortedSchemeNames(cfg.SecuritySchemes) { opts = append(opts, openapi.SecuritySchemeFromConfig(name, securitySchemeConfig(cfg.SecuritySchemes[name]))) } + filters, err := filtersFromConfig(cfg) + if err != nil { + return nil, err + } + if len(filters) > 0 { + opts = append(opts, openapi.WithFilters(filters...)) + } return opts, nil } +func filtersFromConfig(cfg Config) ([]openapi.Filter, error) { + var filters []openapi.Filter + for _, expression := range cfg.Filters { + filter, err := openapi.FilterOperationExpression(expression) + if err != nil { + return nil, err + } + filters = append(filters, filter) + } + if cfg.PruneUnusedComponents { + filters = append(filters, openapi.PruneUnusedComponents()) + } + return filters, nil +} + func specMetadata(cfg Config) openapi.SpecMetadata { serverDescriptions := make([]string, len(cfg.Servers)) for i, server := range cfg.Servers { diff --git a/internal/cli/pipeline_test.go b/internal/cli/pipeline_test.go index 5e9bab3..17c73bb 100644 --- a/internal/cli/pipeline_test.go +++ b/internal/cli/pipeline_test.go @@ -96,6 +96,75 @@ func TestRunPipelineGeneratesSpecFromRouteManifest(t *testing.T) { } } +func TestRunManifestPipelineFiltersPublicExtensionAndPrunesComponents(t *testing.T) { + dir := writeUserModule(t) + writeFile(t, filepath.Join(dir, "internal/server/public.go"), `package server + +import "github.com/fox-gonic/fox" + +// GetPublicUser fetches a public user. +// +// openapi: +// +// x-public: true +func GetPublicUser(ctx *fox.Context, req GetUserRequest) (User, error) { + return User{}, nil +} + +// GetInternalUser fetches an internal user. +// +// openapi: +// +// x-public: false +func GetInternalUser(ctx *fox.Context, req GetUserRequest) (User, error) { + return User{}, nil +} +`) + manifestPath := filepath.Join(dir, "routes.manifest.json") + if err := os.WriteFile(manifestPath, []byte(`{ + "version": "fox.route-manifest/v1", + "routes": [ + { + "method": "GET", + "path": "/public-users/:id", + "handler": "example.com/app/internal/server.GetPublicUser" + }, + { + "method": "GET", + "path": "/internal-users/:id", + "handler": "example.com/app/internal/server.GetInternalUser" + } + ] +}`), 0o644); err != nil { + t.Fatal(err) + } + + data, warnings, err := RunPipeline(Config{ + RouteManifest: manifestPath, + Out: "api/openapi.yaml", + Format: "yaml", + Sources: []string{"./internal/server"}, + Info: InfoConfig{Title: "Manifest API", Version: "1.0.0"}, + Filters: []string{"x-public != false"}, + PruneUnusedComponents: true, + Workdir: dir, + }) + + if err != nil { + t.Fatal(err) + } + if len(warnings) != 0 { + t.Fatalf("warnings = %#v", warnings) + } + out := string(data) + if !strings.Contains(out, "/public-users/{id}:") { + t.Fatalf("generated spec missing public path:\n%s", out) + } + if strings.Contains(out, "/internal-users/{id}:") { + t.Fatalf("generated spec includes internal path:\n%s", out) + } +} + func TestRunPipelineRejectsUnsupportedRouteManifestVersion(t *testing.T) { dir := t.TempDir() manifestPath := filepath.Join(dir, "routes.manifest.json") diff --git a/mount.go b/mount.go index 1a11153..406f3b6 100644 --- a/mount.go +++ b/mount.go @@ -49,7 +49,7 @@ func Mount(router Router, g *Generator, opts ...MountOption) { opt(&config) } - g.ensureGenerated() + _ = g.ensureGenerated() if config.yamlPath != "" { router.GET(config.yamlPath, YAMLHandler(g)) diff --git a/openapi.go b/openapi.go index 0f2b62f..d44dd17 100644 --- a/openapi.go +++ b/openapi.go @@ -40,7 +40,9 @@ type Generator struct { groups []groupDoc formatters map[reflect.Type]*openapi3.Schema errorSchema reflect.Type + filters []Filter generated bool + err error } // Info sets the OpenAPI info title and version. @@ -58,6 +60,13 @@ func Server(url string) Option { } } +// WithFilters applies post-generation filters before the spec is serialized. +func WithFilters(filters ...Filter) Option { + return func(g *Generator) { + g.filters = append(g.filters, filters...) + } +} + // New creates a Generator and immediately scans the engine's current routes. func New(engine *fox.Engine, opts ...Option) *Generator { components := openapi3.NewComponents() @@ -94,19 +103,30 @@ func NewFromRouteManifest(manifest RouteManifest, opts ...Option) *Generator { 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. +// Spec returns the generated OpenAPI model. Generation errors are ignored; call +// SpecErr or Err when the generator was configured with filters that can fail. func (g *Generator) Spec() *openapi3.T { - g.ensureGenerated() + _ = g.ensureGenerated() return g.spec } +// SpecErr returns the generated OpenAPI model and any generation error. +func (g *Generator) SpecErr() (*openapi3.T, error) { + err := g.ensureGenerated() + return g.spec, err +} + +// Err returns the generation error, if any. +func (g *Generator) Err() error { + return g.ensureGenerated() +} + // Regenerate forces a full re-scan of the engine's routes on the next access. // Useful when routes are added dynamically and the caller wants the next // JSON()/YAML() call to reflect them without retaining stale state. func (g *Generator) Regenerate() { g.generated = false + g.err = nil g.warnings = nil g.schemaNames = make(map[reflect.Type]string) g.schemaByName = make(map[string]reflect.Type) @@ -117,23 +137,58 @@ func (g *Generator) Regenerate() { g.spec.Components.Responses = openapi3.ResponseBodies{} } -func (g *Generator) ensureGenerated() { +func (g *Generator) ensureGenerated() error { if g.generated { - return + return g.err } g.addSourceWarnings() g.addHTTPErrorSchema() g.generate() + if len(g.filters) > 0 { + filtered, err := cloneSpec(g.spec) + if err != nil { + g.err = fmt.Errorf("prepare filtered spec: %w", err) + g.generated = true + return g.err + } + if err := ApplyFilters(filtered, g.filters...); err != nil { + g.err = err + g.generated = true + return g.err + } + g.spec = filtered + } + g.err = nil g.generated = true + return g.err +} + +func cloneSpec(spec *openapi3.T) (*openapi3.T, error) { + data, err := json.Marshal(spec) + if err != nil { + return nil, err + } + var clone openapi3.T + if err := json.Unmarshal(data, &clone); err != nil { + return nil, err + } + return &clone, nil } // Warnings returns non-fatal generation warnings, triggering generation if it -// has not yet happened. +// has not yet happened. Call WarningsErr or Err to inspect fatal generation +// errors. func (g *Generator) Warnings() []string { - g.ensureGenerated() + _ = g.ensureGenerated() return append([]string(nil), g.warnings...) } +// WarningsErr returns non-fatal generation warnings and any generation error. +func (g *Generator) WarningsErr() ([]string, error) { + err := g.ensureGenerated() + return append([]string(nil), g.warnings...), err +} + func (g *Generator) warnf(format string, args ...any) { g.warnings = append(g.warnings, fmt.Sprintf(format, args...)) } @@ -149,13 +204,17 @@ func (g *Generator) addSourceWarnings() { // JSON serializes the generated spec as formatted JSON. func (g *Generator) JSON() ([]byte, error) { - g.ensureGenerated() + if err := g.ensureGenerated(); err != nil { + return nil, err + } return json.MarshalIndent(g.spec, "", " ") } // YAML serializes the generated spec as YAML. func (g *Generator) YAML() ([]byte, error) { - g.ensureGenerated() + if err := g.ensureGenerated(); err != nil { + return nil, err + } return yaml.Marshal(g.spec) } diff --git a/openapi_test.go b/openapi_test.go index 57af37b..bb3a16b 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -2,6 +2,7 @@ package openapi_test import ( "encoding/json" + "errors" "net/http" "net/http/httptest" "reflect" @@ -309,6 +310,39 @@ func TestGenerateIsLazyAndPicksUpRoutesAfterNew(t *testing.T) { require.Contains(t, paths, "/users") } +func TestSpecErrReturnsGenerationFailure(t *testing.T) { + engine := fox.New() + engine.GET("/users/:id", getUser) + + g := openapi.New(engine, openapi.WithFilters(func(*openapi3.T) error { + return errors.New("filter failed") + })) + + spec, err := g.SpecErr() + + require.NotNil(t, spec) + require.ErrorContains(t, err, "filter failed") + require.ErrorContains(t, g.Err(), "filter failed") + _, err = g.WarningsErr() + require.ErrorContains(t, err, "filter failed") + require.NotNil(t, g.Spec()) +} + +func TestFailedFilterDoesNotCachePartiallyMutatedSpec(t *testing.T) { + engine := fox.New() + engine.GET("/users/:id", getUser) + + g := openapi.New(engine, openapi.WithFilters(func(spec *openapi3.T) error { + spec.Paths.Delete("/users/{id}") + return errors.New("filter failed") + })) + + spec, err := g.SpecErr() + + require.ErrorContains(t, err, "filter failed") + require.NotNil(t, spec.Paths.Value("/users/{id}")) +} + func TestMountExcludesSpecEndpointsFromGeneratedPaths(t *testing.T) { engine := fox.New() engine.GET("/users/:id", getUser)