From 7fd64a58b02a9d32ff5b1dedde3da244da88f35e Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Sat, 9 May 2026 11:36:09 +0800 Subject: [PATCH 01/12] feat(cli): support filtered OpenAPI specs Add a post-generation filter pipeline with operation expression filters and unused component pruning. Wire repeatable CLI/config filters through entry and route-manifest generation while keeping legacy extension flags compatible. --- README.md | 182 ++++++++++----- README.zh-CN.md | 162 ++++++++----- cmd/fox-openapi/main.go | 9 + cmd/fox-openapi/main_test.go | 11 + filter.go | 426 ++++++++++++++++++++++++++++++++++ filter_test.go | 144 ++++++++++++ internal/cli/config.go | 92 +++++--- internal/cli/config_test.go | 7 + internal/cli/driver.go | 35 ++- internal/cli/driver_test.go | 5 + internal/cli/pipeline.go | 26 ++- internal/cli/pipeline_test.go | 69 ++++++ mount.go | 2 +- openapi.go | 28 ++- 14 files changed, 1026 insertions(+), 172 deletions(-) create mode 100644 filter.go create mode 100644 filter_test.go diff --git a/README.md b/README.md index bbc2bf9..701b4f0 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,34 @@ 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) +``` + `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 +107,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 +174,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 +182,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 +196,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 +283,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 +300,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 +311,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 +405,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 +440,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 +489,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..021e0a3 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,43 @@ 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) +``` + +`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 +119,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 +138,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 +234,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 +249,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 +260,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 +348,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 +382,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 +425,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..15381a2 --- /dev/null +++ b/filter.go @@ -0,0 +1,426 @@ +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. Matching extensions are also removed from retained +// operations so project-private routing metadata does not leak into derived +// documents. +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 got == value { + return false + } + 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 := strings.Split(expression, "||") + 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 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 + } + 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 value[1 : len(value)-1], true + } + return "", false +} + +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) + } + return fmt.Sprint(left) == fmt.Sprint(right) +} + +func reachableComponentRefs(spec *openapi3.T) (map[string]struct{}, error) { + reachable := map[string]struct{}{} + queue := []string{} + addRef := func(ref string) { + 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 := componentValue(spec.Components, ref) + if !ok { + continue + } + value, err := jsonObject(component) + if err != nil { + return nil, fmt.Errorf("inspect component %s refs: %w", ref, err) + } + collectRefs(value, 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 { + if key == "$ref" { + if ref, ok := child.(string); ok && strings.HasPrefix(ref, "#/components/") { + addRef(ref) + } + } + collectRefs(child, addRef) + } + case []any: + for _, child := range typed { + collectRefs(child, addRef) + } + } +} + +func collectSecurityRequirementRefs(requirements openapi3.SecurityRequirements, addRef func(string)) { + for _, requirement := range requirements { + for name := range requirement { + addRef("#/components/securitySchemes/" + escapeJSONPointer(name)) + } + } +} + +func componentValue(components *openapi3.Components, ref string) (any, bool) { + group, name, ok := splitComponentRef(ref) + if !ok { + return nil, false + } + switch group { + case "schemas": + value, ok := components.Schemas[name] + return value, ok + case "responses": + value, ok := components.Responses[name] + return value, ok + case "parameters": + value, ok := components.Parameters[name] + return value, ok + case "requestBodies": + value, ok := components.RequestBodies[name] + return value, ok + case "headers": + value, ok := components.Headers[name] + return value, ok + case "securitySchemes": + value, ok := components.SecuritySchemes[name] + return value, ok + case "examples": + value, ok := components.Examples[name] + return value, ok + case "links": + value, ok := components.Links[name] + return value, ok + case "callbacks": + value, ok := components.Callbacks[name] + return value, ok + default: + return nil, false + } +} + +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) { + parts := strings.Split(ref, "/") + if len(parts) != 4 || parts[0] != "#" || parts[1] != "components" { + return "", "", false + } + return unescapeJSONPointer(parts[2]), unescapeJSONPointer(parts[3]), 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..fa75a61 --- /dev/null +++ b/filter_test.go @@ -0,0 +1,144 @@ +package openapi_test + +import ( + "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 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 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 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 TestFilterOperationExpressionRejectsUnsupportedExpression(t *testing.T) { + _, err := openapi.FilterOperationExpression("x-public > false") + + 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..5f96e67 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. @@ -81,9 +83,9 @@ 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 +99,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 +174,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 +212,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..de0692b 100644 --- a/internal/cli/pipeline.go +++ b/internal/cli/pipeline.go @@ -84,9 +84,9 @@ func runManifestPipeline(cfg Config) ([]byte, []string, error) { 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 +117,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..2485afe 100644 --- a/openapi.go +++ b/openapi.go @@ -40,6 +40,7 @@ type Generator struct { groups []groupDoc formatters map[reflect.Type]*openapi3.Schema errorSchema reflect.Type + filters []Filter generated bool } @@ -58,6 +59,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() @@ -98,7 +106,7 @@ func NewFromRouteManifest(manifest RouteManifest, opts ...Option) *Generator { // route table; subsequent calls also re-walk so freshly registered routes are // reflected. func (g *Generator) Spec() *openapi3.T { - g.ensureGenerated() + _ = g.ensureGenerated() return g.spec } @@ -117,20 +125,24 @@ func (g *Generator) Regenerate() { g.spec.Components.Responses = openapi3.ResponseBodies{} } -func (g *Generator) ensureGenerated() { +func (g *Generator) ensureGenerated() error { if g.generated { - return + return nil } g.addSourceWarnings() g.addHTTPErrorSchema() g.generate() + if err := ApplyFilters(g.spec, g.filters...); err != nil { + return err + } g.generated = true + return nil } // Warnings returns non-fatal generation warnings, triggering generation if it // has not yet happened. func (g *Generator) Warnings() []string { - g.ensureGenerated() + _ = g.ensureGenerated() return append([]string(nil), g.warnings...) } @@ -149,13 +161,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) } From 185e06ef521432d0dccdb50f41c84a96af8d8902 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Sat, 9 May 2026 12:12:43 +0800 Subject: [PATCH 02/12] fix: address OpenAPI filter review feedback Expose generation errors through SpecErr, Err, and WarningsErr so filter failures are visible to library and CLI callers. Handle quoted OR literals in filter expressions and prune components from a single JSON traversal while preserving subpath component references. --- filter.go | 117 ++++++++++++++++++++++++++------------- filter_test.go | 51 +++++++++++++++++ internal/cli/driver.go | 7 ++- internal/cli/pipeline.go | 5 +- openapi.go | 35 +++++++++--- openapi_test.go | 18 ++++++ 6 files changed, 181 insertions(+), 52 deletions(-) diff --git a/filter.go b/filter.go index 15381a2..292a443 100644 --- a/filter.go +++ b/filter.go @@ -155,7 +155,10 @@ type operationFilterCondition struct { type operationFilterConditionGroup []operationFilterCondition func parseOperationFilterExpressionGroup(expression string) (operationFilterConditionGroup, error) { - parts := strings.Split(expression, "||") + parts, err := splitFilterAlternatives(expression) + if err != nil { + return nil, err + } conditions := make(operationFilterConditionGroup, 0, len(parts)) for _, part := range parts { condition, err := parseOperationFilterExpression(part) @@ -167,6 +170,39 @@ func parseOperationFilterExpressionGroup(expression string) (operationFilterCond 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 quote == '"' && 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{"!=", "==", "="} { @@ -254,6 +290,11 @@ 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 } @@ -283,15 +324,11 @@ func reachableComponentRefs(spec *openapi3.T) (map[string]struct{}, error) { for len(queue) > 0 { ref := queue[0] queue = queue[1:] - component, ok := componentValue(spec.Components, ref) + component, ok := componentJSONValue(root, ref) if !ok { continue } - value, err := jsonObject(component) - if err != nil { - return nil, fmt.Errorf("inspect component %s refs: %w", ref, err) - } - collectRefs(value, addRef) + collectRefs(component, addRef) } return reachable, nil } @@ -348,42 +385,25 @@ func collectSecurityRequirementRefs(requirements openapi3.SecurityRequirements, } } -func componentValue(components *openapi3.Components, ref string) (any, bool) { +func componentJSONValue(root any, ref string) (any, bool) { group, name, ok := splitComponentRef(ref) if !ok { return nil, false } - switch group { - case "schemas": - value, ok := components.Schemas[name] - return value, ok - case "responses": - value, ok := components.Responses[name] - return value, ok - case "parameters": - value, ok := components.Parameters[name] - return value, ok - case "requestBodies": - value, ok := components.RequestBodies[name] - return value, ok - case "headers": - value, ok := components.Headers[name] - return value, ok - case "securitySchemes": - value, ok := components.SecuritySchemes[name] - return value, ok - case "examples": - value, ok := components.Examples[name] - return value, ok - case "links": - value, ok := components.Links[name] - return value, ok - case "callbacks": - value, ok := components.Callbacks[name] - return value, ok - default: + 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{}) { @@ -408,11 +428,28 @@ func pruneByGroup[V any](m map[string]V, group string, reachable map[string]stru } func splitComponentRef(ref string) (string, string, bool) { - parts := strings.Split(ref, "/") - if len(parts) != 4 || parts[0] != "#" || parts[1] != "components" { + const prefix = "#/components/" + if !strings.HasPrefix(ref, prefix) { return "", "", false } - return unescapeJSONPointer(parts[2]), unescapeJSONPointer(parts[3]), true + 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 { diff --git a/filter_test.go b/filter_test.go index fa75a61..2c4cb9a 100644 --- a/filter_test.go +++ b/filter_test.go @@ -76,6 +76,30 @@ func TestPruneUnusedComponentsRemovesFilteredOperationRefs(t *testing.T) { 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 TestOperationExtensionBoolDefault(t *testing.T) { op := openapi.OperationContext{Operation: &openapi3.Operation{Extensions: map[string]any{"x-public": false}}} @@ -137,8 +161,35 @@ func TestFilterOperationExpressionSupportsOrConditions(t *testing.T) { 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 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/driver.go b/internal/cli/driver.go index 5f96e67..b9be019 100644 --- a/internal/cli/driver.go +++ b/internal/cli/driver.go @@ -66,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{ @@ -81,7 +85,6 @@ func main() { }, }) var out []byte - var err2 error {{- if eq .Format "json" }} out, err2 = g.JSON() {{- else }} diff --git a/internal/cli/pipeline.go b/internal/cli/pipeline.go index de0692b..626e637 100644 --- a/internal/cli/pipeline.go +++ b/internal/cli/pipeline.go @@ -80,7 +80,10 @@ 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 { diff --git a/openapi.go b/openapi.go index 2485afe..239e693 100644 --- a/openapi.go +++ b/openapi.go @@ -42,6 +42,7 @@ type Generator struct { errorSchema reflect.Type filters []Filter generated bool + err error } // Info sets the OpenAPI info title and version. @@ -102,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. Call SpecErr or Err when the +// generator was configured with filters that can fail. func (g *Generator) Spec() *openapi3.T { _ = 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) @@ -127,25 +139,30 @@ func (g *Generator) Regenerate() { func (g *Generator) ensureGenerated() error { if g.generated { - return nil + return g.err } g.addSourceWarnings() g.addHTTPErrorSchema() g.generate() - if err := ApplyFilters(g.spec, g.filters...); err != nil { - return err - } + g.err = ApplyFilters(g.spec, g.filters...) g.generated = true - return nil + return g.err } // 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() 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...)) } diff --git a/openapi_test.go b/openapi_test.go index 57af37b..3c3541f 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,23 @@ 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") +} + func TestMountExcludesSpecEndpointsFromGeneratedPaths(t *testing.T) { engine := fox.New() engine.GET("/users/:id", getUser) From 1fd9f889f9dafdce71ce0f31d58341b99708bd34 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Sat, 9 May 2026 14:52:17 +0800 Subject: [PATCH 03/12] fix: keep filter failures from corrupting cached specs Run filters against a cloned generated spec and only publish the filtered document after all filters succeed. Handle escaped single quotes in filter expression literals. --- filter.go | 21 +++++++++++++++++++-- filter_test.go | 21 +++++++++++++++++++++ openapi.go | 28 +++++++++++++++++++++++++++- openapi_test.go | 15 +++++++++++++++ 4 files changed, 82 insertions(+), 3 deletions(-) diff --git a/filter.go b/filter.go index 292a443..3bb8122 100644 --- a/filter.go +++ b/filter.go @@ -177,7 +177,7 @@ func splitFilterAlternatives(expression string) ([]string, error) { for i := 0; i < len(expression); i++ { c := expression[i] if quote != 0 { - if quote == '"' && c == '\\' { + if c == '\\' { i++ continue } @@ -252,11 +252,28 @@ func stripQuotes(value string) (string, bool) { } } if first == '\'' && last == '\'' { - return value[1 : len(value)-1], true + 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) { diff --git a/filter_test.go b/filter_test.go index 2c4cb9a..a5ed935 100644 --- a/filter_test.go +++ b/filter_test.go @@ -182,6 +182,27 @@ func TestFilterOperationExpressionKeepsQuotedOrLiteralTogether(t *testing.T) { 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") diff --git a/openapi.go b/openapi.go index 239e693..03faccf 100644 --- a/openapi.go +++ b/openapi.go @@ -144,11 +144,37 @@ func (g *Generator) ensureGenerated() error { g.addSourceWarnings() g.addHTTPErrorSchema() g.generate() - g.err = ApplyFilters(g.spec, g.filters...) + 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. Call WarningsErr or Err to inspect fatal generation // errors. diff --git a/openapi_test.go b/openapi_test.go index 3c3541f..002f1bf 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -327,6 +327,21 @@ func TestSpecErrReturnsGenerationFailure(t *testing.T) { require.ErrorContains(t, err, "filter failed") } +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) From b8d61f5b192a5ca1e3e14090eacac4d871c83e77 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Sat, 9 May 2026 15:03:52 +0800 Subject: [PATCH 04/12] fix: preserve component refs from OpenAPI links Keep components referenced by discriminator mappings and operationRef values during unused component pruning. Make Spec return nil when generation fails so callers do not accidentally consume an unfiltered spec. --- filter.go | 22 ++++++++++++++++- filter_test.go | 63 +++++++++++++++++++++++++++++++++++++++++++++++++ openapi.go | 4 +++- openapi_test.go | 1 + 4 files changed, 88 insertions(+), 2 deletions(-) diff --git a/filter.go b/filter.go index 3bb8122..996f2cf 100644 --- a/filter.go +++ b/filter.go @@ -380,10 +380,13 @@ func collectRefs(value any, addRef func(string)) { switch typed := value.(type) { case map[string]any: for key, child := range typed { - if key == "$ref" { + switch key { + case "$ref", "operationRef": if ref, ok := child.(string); ok && strings.HasPrefix(ref, "#/components/") { addRef(ref) } + case "mapping": + collectComponentRefStrings(child, addRef) } collectRefs(child, addRef) } @@ -394,6 +397,23 @@ func collectRefs(value any, addRef func(string)) { } } +func collectComponentRefStrings(value any, addRef func(string)) { + switch typed := value.(type) { + case string: + if strings.HasPrefix(typed, "#/components/") { + addRef(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 { diff --git a/filter_test.go b/filter_test.go index a5ed935..40b4fa1 100644 --- a/filter_test.go +++ b/filter_test.go @@ -100,6 +100,69 @@ func TestPruneUnusedComponentsKeepsComponentReachedBySubpathRef(t *testing.T) { 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 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}}} diff --git a/openapi.go b/openapi.go index 03faccf..bada360 100644 --- a/openapi.go +++ b/openapi.go @@ -106,7 +106,9 @@ func NewFromRouteManifest(manifest RouteManifest, opts ...Option) *Generator { // Spec returns the generated OpenAPI model. Call SpecErr or Err when the // generator was configured with filters that can fail. func (g *Generator) Spec() *openapi3.T { - _ = g.ensureGenerated() + if err := g.ensureGenerated(); err != nil { + return nil + } return g.spec } diff --git a/openapi_test.go b/openapi_test.go index 002f1bf..a0a5cf9 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -325,6 +325,7 @@ func TestSpecErrReturnsGenerationFailure(t *testing.T) { require.ErrorContains(t, g.Err(), "filter failed") _, err = g.WarningsErr() require.ErrorContains(t, err, "filter failed") + require.Nil(t, g.Spec()) } func TestFailedFilterDoesNotCachePartiallyMutatedSpec(t *testing.T) { From b33e9a5f3e08c3c82f155c0c62f6478bd7b1b84b Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Sat, 9 May 2026 15:12:55 +0800 Subject: [PATCH 05/12] fix: compare extension filter values safely Use scalarEqual when excluding operations by extension value so map and slice extension values cannot panic. --- filter.go | 2 +- filter_test.go | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/filter.go b/filter.go index 996f2cf..869b064 100644 --- a/filter.go +++ b/filter.go @@ -92,7 +92,7 @@ func ExcludeOperationsWithExtensionValue(extension string, value any) Filter { if !ok { return true } - if got == value { + if scalarEqual(got, value) { return false } delete(op.Operation.Extensions, extension) diff --git a/filter_test.go b/filter_test.go index 40b4fa1..d30f7ac 100644 --- a/filter_test.go +++ b/filter_test.go @@ -24,6 +24,27 @@ func TestFilterOperationsKeepsMatchingOperationsAndPrunesEmptyPaths(t *testing.T 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")) +} + func TestPruneUnusedComponentsRemovesFilteredOperationRefs(t *testing.T) { spec := &openapi3.T{ Paths: openapi3.NewPaths(), From 234fd4f62132048e45bf572cde14ec83a86e0a42 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Sat, 9 May 2026 15:45:52 +0800 Subject: [PATCH 06/12] fix: avoid string fallback for scalar filters Compare cross-type scalar extension values with explicit bool, string, and numeric handling instead of fmt.Sprint. --- filter.go | 49 ++++++++++++++++++++++++++++++++++++++++++++++++- filter_test.go | 19 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/filter.go b/filter.go index 869b064..9ac69b7 100644 --- a/filter.go +++ b/filter.go @@ -300,7 +300,54 @@ func scalarEqual(left, right any) bool { if reflect.TypeOf(left) == reflect.TypeOf(right) { return reflect.DeepEqual(left, right) } - return fmt.Sprint(left) == fmt.Sprint(right) + if leftBool, ok := left.(bool); ok { + rightBool, ok := right.(bool) + return ok && leftBool == rightBool + } + if leftString, ok := left.(string); ok { + rightString, ok := right.(string) + return ok && leftString == rightString + } + leftNumber, leftOK := numericScalar(left) + rightNumber, rightOK := numericScalar(right) + if leftOK || rightOK { + return leftOK && rightOK && 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) { diff --git a/filter_test.go b/filter_test.go index d30f7ac..f957501 100644 --- a/filter_test.go +++ b/filter_test.go @@ -1,6 +1,7 @@ package openapi_test import ( + "encoding/json" "net/http" "testing" @@ -45,6 +46,24 @@ func TestExcludeOperationsWithExtensionValueHandlesNonComparableValues(t *testin require.NotNil(t, spec.Paths.Value("/public")) } +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 TestPruneUnusedComponentsRemovesFilteredOperationRefs(t *testing.T) { spec := &openapi3.T{ Paths: openapi3.NewPaths(), From 4de40545102972886f7d863c8ccb5c3730591b56 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Sat, 9 May 2026 16:04:35 +0800 Subject: [PATCH 07/12] fix: parse numeric filter literals Support numeric operation filter expressions while keeping Spec backward-compatible on generation errors. --- filter.go | 3 +++ filter_test.go | 21 +++++++++++++++++++++ openapi.go | 4 +--- openapi_test.go | 2 +- 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/filter.go b/filter.go index 9ac69b7..fd62c2b 100644 --- a/filter.go +++ b/filter.go @@ -238,6 +238,9 @@ func parseFilterLiteral(value string) any { if unquoted, ok := stripQuotes(value); ok { return unquoted } + if number, err := strconv.ParseFloat(value, 64); err == nil { + return number + } return value } diff --git a/filter_test.go b/filter_test.go index f957501..f704170 100644 --- a/filter_test.go +++ b/filter_test.go @@ -238,6 +238,27 @@ func TestFilterOperationExpressionMatchesExtensionConditions(t *testing.T) { 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{ diff --git a/openapi.go b/openapi.go index bada360..03faccf 100644 --- a/openapi.go +++ b/openapi.go @@ -106,9 +106,7 @@ func NewFromRouteManifest(manifest RouteManifest, opts ...Option) *Generator { // Spec returns the generated OpenAPI model. Call SpecErr or Err when the // generator was configured with filters that can fail. func (g *Generator) Spec() *openapi3.T { - if err := g.ensureGenerated(); err != nil { - return nil - } + _ = g.ensureGenerated() return g.spec } diff --git a/openapi_test.go b/openapi_test.go index a0a5cf9..bb3a16b 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -325,7 +325,7 @@ func TestSpecErrReturnsGenerationFailure(t *testing.T) { require.ErrorContains(t, g.Err(), "filter failed") _, err = g.WarningsErr() require.ErrorContains(t, err, "filter failed") - require.Nil(t, g.Spec()) + require.NotNil(t, g.Spec()) } func TestFailedFilterDoesNotCachePartiallyMutatedSpec(t *testing.T) { From 549bc52f7f53f68f479c477261e2f638460c42c5 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Sat, 9 May 2026 16:14:24 +0800 Subject: [PATCH 08/12] fix: keep discriminator mapping schema names Treat simple discriminator mapping values as local component schema names during unused component pruning. --- filter.go | 2 ++ filter_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/filter.go b/filter.go index fd62c2b..27c3c74 100644 --- a/filter.go +++ b/filter.go @@ -452,6 +452,8 @@ func collectComponentRefStrings(value any, addRef func(string)) { 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 { diff --git a/filter_test.go b/filter_test.go index f704170..41684d7 100644 --- a/filter_test.go +++ b/filter_test.go @@ -173,6 +173,39 @@ func TestPruneUnusedComponentsKeepsDiscriminatorMappingRefs(t *testing.T) { 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 TestPruneUnusedComponentsKeepsOperationRefComponentRefs(t *testing.T) { spec := &openapi3.T{ Paths: openapi3.NewPaths(), From 5ccf3f8546ad024ff67ca54f6e54f9c06336c50b Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Sat, 9 May 2026 16:30:25 +0800 Subject: [PATCH 09/12] fix: narrow discriminator mapping pruning Only treat mapping fields as discriminator mappings when the parent object has a propertyName. --- filter.go | 9 ++++++++- filter_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/filter.go b/filter.go index 27c3c74..e263d35 100644 --- a/filter.go +++ b/filter.go @@ -436,7 +436,9 @@ func collectRefs(value any, addRef func(string)) { addRef(ref) } case "mapping": - collectComponentRefStrings(child, addRef) + if isDiscriminatorObject(typed) { + collectComponentRefStrings(child, addRef) + } } collectRefs(child, addRef) } @@ -447,6 +449,11 @@ func collectRefs(value any, addRef func(string)) { } } +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: diff --git a/filter_test.go b/filter_test.go index 41684d7..dd11cee 100644 --- a/filter_test.go +++ b/filter_test.go @@ -206,6 +206,34 @@ func TestPruneUnusedComponentsKeepsDiscriminatorMappingSchemaNames(t *testing.T) 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(), From 80b296e7604ca1103d252c039f9c3b968e33f544 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Sat, 9 May 2026 16:40:49 +0800 Subject: [PATCH 10/12] refactor: simplify scalar filter equality Rely on same-type DeepEqual and only allow cross-type comparisons for numeric scalar values. --- filter.go | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/filter.go b/filter.go index e263d35..c2e15af 100644 --- a/filter.go +++ b/filter.go @@ -303,18 +303,10 @@ func scalarEqual(left, right any) bool { if reflect.TypeOf(left) == reflect.TypeOf(right) { return reflect.DeepEqual(left, right) } - if leftBool, ok := left.(bool); ok { - rightBool, ok := right.(bool) - return ok && leftBool == rightBool - } - if leftString, ok := left.(string); ok { - rightString, ok := right.(string) - return ok && leftString == rightString - } leftNumber, leftOK := numericScalar(left) rightNumber, rightOK := numericScalar(right) - if leftOK || rightOK { - return leftOK && rightOK && leftNumber == rightNumber + if leftOK && rightOK { + return leftNumber == rightNumber } return false } From 277c0574f8d83e1e12acce558e8d66080aca9846 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Sat, 9 May 2026 16:50:42 +0800 Subject: [PATCH 11/12] docs: clarify entry config type placeholder Explain that Config in supported entry signatures represents the user's own application config type. --- README.md | 3 +++ README.zh-CN.md | 2 ++ 2 files changed, 5 insertions(+) diff --git a/README.md b/README.md index 701b4f0..48cc847 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,9 @@ 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. It watches Go files by default and regenerates the preview when source changes. diff --git a/README.zh-CN.md b/README.zh-CN.md index 021e0a3..f492bc0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -78,6 +78,8 @@ 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: From f5f0b3e118855a8aff6decf941a7842453cb2684 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Sat, 9 May 2026 17:01:48 +0800 Subject: [PATCH 12/12] fix: keep extension exclusion non-destructive Separate operation exclusion from extension cleanup and document that Spec ignores generation errors. --- filter.go | 14 +++++++++++--- filter_test.go | 15 +++++++++++++++ openapi.go | 4 ++-- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/filter.go b/filter.go index c2e15af..aae50b9 100644 --- a/filter.go +++ b/filter.go @@ -80,9 +80,7 @@ func FilterOperations(keep func(OperationContext) bool) Filter { } // ExcludeOperationsWithExtensionValue removes operations whose extension equals -// the provided scalar value. Matching extensions are also removed from retained -// operations so project-private routing metadata does not leak into derived -// documents. +// 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 { @@ -95,6 +93,16 @@ func ExcludeOperationsWithExtensionValue(extension string, value any) Filter { 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 diff --git a/filter_test.go b/filter_test.go index dd11cee..d7b8b61 100644 --- a/filter_test.go +++ b/filter_test.go @@ -44,6 +44,7 @@ func TestExcludeOperationsWithExtensionValueHandlesNonComparableValues(t *testin 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) { @@ -64,6 +65,20 @@ func TestExcludeOperationsWithExtensionValueHandlesNumericScalarTypes(t *testing 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(), diff --git a/openapi.go b/openapi.go index 03faccf..d44dd17 100644 --- a/openapi.go +++ b/openapi.go @@ -103,8 +103,8 @@ func NewFromRouteManifest(manifest RouteManifest, opts ...Option) *Generator { return g } -// Spec returns the generated OpenAPI model. Call SpecErr or Err when the -// generator was configured with filters that can fail. +// 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() return g.spec