Compare commits

..

121 Commits

Author SHA1 Message Date
0d2c6040e2 feat: release v0.28.1 mixed API and generic project workspaces 2026-09-30 00:20:17 +08:00
1052f156bd test: record v0.28 remote generic and managed project acceptance 2026-09-30 00:01:38 +08:00
0b6c0043b6 feat: release v0.28 generic Go project scaffolding and lifecycle entry 2026-09-29 23:58:20 +08:00
f8e0dcc5a6 test: record v0.27 remote HTTP safety and generated consumer acceptance 2026-09-29 23:27:32 +08:00
3b00bb55d1 feat: release v0.27 unified HTTP limits and safe request shutdown 2026-09-29 23:24:19 +08:00
859f1851b1 test: record v0.26 remote i18n and generated consumer acceptance 2026-09-29 13:34:28 +08:00
d1a747c36f feat: release v0.26 portable i18n and presentation adapters 2026-09-29 13:31:42 +08:00
6a95cad184 test: record v0.25 remote transaction and MySQL acceptance 2026-09-26 20:32:23 +08:00
7a177744ab feat: expose transaction failure stages and conservative outcomes 2026-09-26 20:29:45 +08:00
8eddef9cc8 test: record v0.24 remote CORS and generated project acceptance 2026-09-26 19:55:41 +08:00
800974cfff feat: add configurable transport-independent CORS policy 2026-09-26 19:53:28 +08:00
9fade3bedb test: record v0.23 remote consumer and performance acceptance 2026-09-26 18:06:40 +08:00
3bbc6f60d7 feat: add precise diagnostics and optional client copy logging controls 2026-09-26 18:04:33 +08:00
69585dab61 test: record remote explicit configuration release acceptance 2026-09-26 16:16:37 +08:00
d598b2ebee feat: load explicit configuration references and remove implicit env overlays 2026-09-26 16:14:54 +08:00
4bf6e88cfe docs: record remote HTTP client release acceptance 2026-09-26 15:30:32 +08:00
96e7d9629b feat: simplify HTTP client proxy pools and authenticated JSON calls 2026-09-26 15:28:19 +08:00
068e85132f test: record remote configuration release acceptance 2026-09-26 14:49:28 +08:00
c60841f901 feat: unify generated configuration loading and extensible decoders 2026-09-26 14:47:33 +08:00
91a6beb2f3 test: verify remote copyx release and update usage reference 2026-09-25 17:31:48 +08:00
fd83235ce7 feat: replace mapping generation with checked copy utilities 2026-09-25 17:27:28 +08:00
ad9257d202 test: record remote nullable mapping release acceptance 2026-09-25 13:58:45 +08:00
4563a6354f feat: release nullable mappings and split mapping configuration 2026-09-25 13:56:44 +08:00
5fc82509a1 test: record remote generation experience release acceptance 2026-09-25 12:45:29 +08:00
8b588a24cd feat: release generic composition mapping and middleware factories 2026-09-25 12:43:34 +08:00
8493f9dae9 test: record remote contract tooling release acceptance 2026-09-25 02:23:58 +08:00
64e3bd6cfc feat: support primitive response arrays and shared catalog tooling 2026-09-25 02:22:24 +08:00
1f2d8b6a6a test: record remote phased lifecycle release acceptance 2026-09-25 00:37:21 +08:00
16deead70e feat: add generic phased background shutdown to managed services 2026-09-25 00:35:59 +08:00
adcb6f60b6 test: record remote optional GORM release acceptance 2026-09-24 22:27:21 +08:00
b8ab6b3521 feat: add optional GORM pool and SQL logging adapter 2026-09-24 22:25:44 +08:00
da8356e18b test: record remote business development and MySQL release acceptance 2026-09-24 21:58:12 +08:00
f5478cdc76 feat: complete contract-driven business development and MySQL integration 2026-09-24 21:56:04 +08:00
f9afb8f8dd test: record remote lifecycle and endpoint release acceptance 2026-09-24 18:34:52 +08:00
f0991d3ff2 feat: add dependency shutdown and configurable service endpoints 2026-09-24 18:33:24 +08:00
8a348951eb test: record remote managed project release acceptance 2026-09-24 17:36:04 +08:00
158b8002b3 feat: release managed service generation and contract diagnostics 2026-09-24 17:34:21 +08:00
eb69260586 test: record remote App catalog inheritance acceptance 2026-09-24 15:45:54 +08:00
a2a07a39ea fix: inherit App error catalogs on generated routes without local errors 2026-09-24 15:42:03 +08:00
bca2ef92cc test: record remote template and compatibility release acceptance 2026-09-24 15:06:48 +08:00
6e3f025aad feat: add typed error message templates and stable replay snapshots 2026-09-24 15:05:08 +08:00
2041e7f661 test: record remote error catalog and compatibility acceptance 2026-09-24 12:41:30 +08:00
ebfd279f9a feat: add project-owned error catalogs and HTTP 200 failure contracts 2026-09-24 12:40:05 +08:00
bb2caefbb4 test: record remote connector and HTTP configuration acceptance 2026-09-24 02:19:33 +08:00
20e84e5ff8 feat: support database connectors and HTTP server configuration 2026-09-24 02:18:18 +08:00
6200864a64 test: record remote migration release acceptance 2026-09-24 01:49:49 +08:00
a8c973d8a2 feat: add HTTP migration bridges and extensible API binding 2026-09-24 01:47:42 +08:00
7b8eca1a61 docs: make README the complete project integration entry point 2026-09-23 21:10:43 +08:00
35bd724326 test: isolate and record remote response release acceptance 2026-09-23 20:47:28 +08:00
7670b222ac feat: release configurable API response contracts
Define reusable response bodies in .api with typed code mappings, per-route overrides and matching OpenAPI schemas. Add application and callback encoders, early response policy selection, and isolated remote-consumer release verification.

Release v0.8.0-preview after full race, vet, build and local MySQL workflow acceptance.
2026-09-23 20:42:38 +08:00
bef7ea2641 docs: record remote API developer experience acceptance 2026-09-23 18:19:12 +08:00
2913e94a0a feat: declare API errors and authentication with developer tooling 2026-09-23 18:17:43 +08:00
ab8eac5fc4 docs: record remote preview acceptance and verify example error logging 2026-09-23 17:37:53 +08:00
0ce3efb6fc fix: normalize generated infrastructure file endings 2026-09-23 17:35:00 +08:00
2c74dc0859 feat: complete API-driven projects with infrastructure and business acceptance 2026-09-23 17:33:50 +08:00
04a1f7999d docs: record advanced module contracts and isolated Linux acceptance 2026-09-23 14:58:48 +08:00
208a9c3fbd feat: add independent platform modules and multi-provider storage 2026-09-23 14:51:14 +08:00
f91034477b docs: record feature module APIs and Linux Redis acceptance 2026-09-23 13:46:40 +08:00
b9259e628e fix: close request bodies when HTTP client rejects before transport 2026-09-23 13:40:31 +08:00
fe66221a9c feat: add cache, outbound HTTP, request protection, tasks and module scaffolding 2026-09-23 13:38:25 +08:00
467c890363 docs: record unified transport startup and shutdown acceptance 2026-09-23 13:02:46 +08:00
be620d2243 fix: bound native shutdown and close connections that become idle during drain 2026-09-23 12:57:27 +08:00
8e18528fc2 feat: configure application transports and unify graceful shutdown 2026-09-23 12:49:56 +08:00
59bf04a461 docs: record verified HTTP2 proxy and rolling drain acceptance 2026-09-23 11:18:25 +08:00
b54a303ccd test: add isolated Nginx HTTP2 upstream and rolling drain checks 2026-09-23 11:09:34 +08:00
84670f7be1 docs: record HTTP2 mixed soak and Linux flow control acceptance 2026-09-23 10:54:14 +08:00
fa2be5e016 test: verify HTTP2 connection window recovery and shutdown 2026-09-23 10:32:28 +08:00
dfe8a1a9af test: add TLS mixed soak with paced clients 2026-09-23 10:18:49 +08:00
830cb3a7ad test: complete bounded HTTP2 TLS soak and flow control checks 2026-09-23 10:06:30 +08:00
d94df4fc54 fix: bound HTTP2 soak resources and cleanup 2026-09-23 01:23:20 +08:00
1a534c7976 fix: allow proxy image startup time 2026-09-23 00:52:49 +08:00
2b93545507 chore: support configurable Go module proxy 2026-09-23 00:47:19 +08:00
15825f0ab8 docs: update HTTP2 acceptance gates 2026-09-23 00:23:02 +08:00
1b9215114f feat: observe HTTP2 reset and goaway frames 2026-09-23 00:20:37 +08:00
73a05b8c3c fix: extend HTTP2 soak test timeout 2026-09-22 23:51:11 +08:00
64a9fde204 test: verify HTTP2 proxy negotiation 2026-09-22 23:43:34 +08:00
ca0e1d6b92 docs: document HTTP2 soak command 2026-09-22 23:37:35 +08:00
98f17660c8 fix: keep HTTP2 soak context through response body 2026-09-22 23:35:23 +08:00
c8660026ce chore: add HTTP2 Linux soak runner 2026-09-22 23:26:37 +08:00
695bdb7a00 fix: count only active HTTP2 goaway sessions 2026-09-22 23:20:57 +08:00
7e9e8aa67c feat: count graceful HTTP2 goaways 2026-09-22 23:16:06 +08:00
de33eda846 test: add configurable HTTP2 TLS soak 2026-09-22 23:10:19 +08:00
dd7d3fef0a feat: expose HTTP2 header and protocol limits 2026-09-22 22:54:55 +08:00
46c5c9406c fix: apply default HTTP2 body limit 2026-09-22 22:43:07 +08:00
1487a96f67 feat: harden experimental HTTP2 request limits 2026-09-22 22:38:37 +08:00
1a20ab53ac test: wait for native connection slot release 2026-09-22 22:28:32 +08:00
a072da4a31 feat: make HTTP2 shutdown graceful 2026-09-22 22:25:35 +08:00
fcd6f48b53 feat: add opt-in HTTP2 transport experiment 2026-09-22 19:08:19 +08:00
83633f724e docs: define native HTTP2 entry criteria 2026-09-22 18:53:03 +08:00
a523e6ba3f feat: make native TLS protocol boundary explicit 2026-09-22 18:39:57 +08:00
4a8feaca36 test: wait for native request slot release 2026-09-22 18:24:51 +08:00
95dc4568f6 feat: add trusted proxy identity and overload stats 2026-09-22 18:19:39 +08:00
2259606f72 chore: label proxy timeout snapshot 2026-09-22 17:38:03 +08:00
35b7470be2 fix: keep proxy verifier control stdin 2026-09-22 17:35:51 +08:00
4f97aa0f3d test: inspect proxy drain state 2026-09-22 16:16:36 +08:00
3b4d0a3fad test: force proxy container drain 2026-09-22 16:12:22 +08:00
a4d843b8f4 test: drain proxy before native shutdown 2026-09-22 16:07:37 +08:00
fadd17a4d4 test: drain proxy timeout before shutdown 2026-09-22 16:03:37 +08:00
ac46071dfe chore: remove unused proxy verifier import 2026-09-22 15:28:25 +08:00
52d603c31e test: diagnose proxy startup failures 2026-09-22 15:27:49 +08:00
42021538b2 fix: report proxy shutdown failures 2026-09-22 15:23:46 +08:00
ca707761fc fix: stabilize proxy verification readiness 2026-09-22 15:15:32 +08:00
30fdad44c2 test: add reverse proxy deployment check 2026-09-22 15:06:17 +08:00
b7dc36fc99 test: verify request overload recovery 2026-09-22 14:57:25 +08:00
8e474a24d9 feat: expose native server stats 2026-09-22 10:04:26 +08:00
ef98d1dd8d feat: add transport-neutral request metrics 2026-09-22 10:00:02 +08:00
5ab906098f feat: stream native request bodies 2026-09-22 09:57:09 +08:00
c5e415ca52 release: prepare v0.6.0-preview 2026-09-22 01:38:42 +08:00
9c9c8ab59a test: validate native TLS on Linux 2026-09-22 01:14:21 +08:00
e6c82a01fe test: add TLS performance matrix 2026-09-21 05:13:51 +08:00
847621d218 feat: add native TLS serving 2026-09-21 04:44:47 +08:00
4daf3b211c feat: cancel native requests on disconnect 2026-09-21 04:35:13 +08:00
62b0f09546 feat: support native streaming responses 2026-09-21 04:02:04 +08:00
694374fde8 docs: record native chunked support 2026-09-21 03:49:49 +08:00
22f1c9ebfb feat: support bounded chunked request bodies 2026-09-21 03:47:55 +08:00
b3cb9e8857 test: exercise native connection churn 2026-09-21 03:29:38 +08:00
efd3163902 test: verify native connection capacity recovery 2026-09-21 03:17:17 +08:00
241a568c83 test: cover native slow bodies and pipelining 2026-09-21 03:03:27 +08:00
22939fdb0b fix: harden native http protocol boundaries 2026-09-21 02:54:42 +08:00
3d5f225fb2 test: compare native concurrency and server memory 2026-09-21 02:35:48 +08:00
c36bb70f96 test: add native tcp performance baseline 2026-09-21 02:14:52 +08:00
777 changed files with 94064 additions and 981 deletions

151
ADVANCED.md Normal file
View File

@@ -0,0 +1,151 @@
# 扩展业务模块
本批提供八类扩展,以及分页、审计和显式数据库迁移。模块按需导入;`omni.New` 与业务 Handler 不绑定存储、队列或网络实现。JSON、配置和新示例使用 lowerCamelCase;Go 导出标识符和 `_test.go` 保持语言约定。
按本次范围决定,**不集成 Orbit,不引入 Orbit SDK,也不实现工作流编排**。通用消息队列与 Orbit 的持久流程、执行授权、结果订阅和人工恢复不是等价能力。业务项目以后需要 Orbit,可独立依赖它的 SDK;当前没有承诺只换队列适配器就能迁移其全部语义。
## 能力与边界
| 模块 | 本批实现 | 明确边界 |
| --- | --- | --- |
| `openapi` | 路由与 OpenAPI 3.0.3 同时注册、DTO schema、响应信封 | 显式声明请求/响应;不自动推断任意 Handler 行为,不附带 Swagger UI |
| `auth` | RS256/EdDSA JWT 验证与公钥轮换、可撤销 Session、Policy 接口 | 登录验证和业务授权规则由接入方提供;不自动获取 token 指定的密钥 |
| `telemetry` | HTTP 入站/出站、Redis、SQL spans,HTTP 指标和 traceId 日志关联 | 注入 OTel exporter/reader 后才外发;SQL Query span 不覆盖后续遍历;不接管全局 provider |
| `config` / `logx` | JSON/YAML/XML 文件、显式环境引用、动态日志级别、轮转/脱敏/堆栈/慢请求 | 热更新只开放日志级别;连接池、监听、认证等静态变更要求重启 |
| `resilience` | 失败阈值、熔断冷却、单探针恢复、上游并发隔离 | 一个实例对应一个依赖;默认内存状态,不做跨进程全局熔断 |
| `middleware/redis` | 跨副本限流、幂等 claim/回放、容量限制 | 专用 Redis namespace;TTL 窗口内保护不等于永久业务去重 |
| `queue` / `queue/redis` | 持久消息接口、Streams 接管、有限重试、失败消息管理 | 至少一次交付,处理器必须幂等;持久性取决于 Redis 部署 |
| `storage` | Local、S3/R2、OSS、临时下载、受限直传、云端分片 | 云平台专属功能单独扩展;本批没有云账号实网验收 |
| `database/migrate` | MySQL 显式部署迁移、摘要预览、失败 dirty 阻断 | 不随应用启动自动改表;无自动 down/force;已应用文件摘要需部署方留存校对 |
| `audit` | 可注入 Sink、字段白名单、JSON 行串行输出、错误返回 | 不是防篡改存储;与业务同事务的审计要实现事务型 Sink |
| `pagination` | page/pageSize、最大偏移与页长、统一结果 | offset 分页,不承诺跨页快照,不实现游标分页 |
日志的配置、脱敏边界、轮转和慢请求记录见 [logx 使用说明](logx/README.md)。框架保留通用 Context/结构化字段扩展,不新增固定业务身份上下文。
## 对象存储
业务只持有 `storage.Store`,调用 `Put`、`Get`、`Delete`。构造函数属于启动组合处:
```go
type FileService struct { Files storage.Store }
// Local:私有目录,流式临时文件 + 原子替换。
files, err := local.New("/srv/app/objects", 32<<20)
// S3:AWS/MinIO 或其他经过契约测试的 S3 兼容服务。
files, err := s3.Open(s3.Config{
Endpoint: "s3.us-east-1.amazonaws.com", Region: "us-east-1",
Bucket: "example-bucket", Secure: true, MaxBytes: 32<<20,
Credentials: credentialProvider, Transport: transport,
})
// Cloudflare R2:复用 S3 适配器,签名区域为 auto。
cfg, err := s3.R2Config(accountID, bucket, credentialProvider)
cfg.Transport = transport
files, err := s3.Open(cfg)
// OSS:单独适配官方 Go SDK v2,保留平台签名与凭据提供器。
client := ossSdk.NewClient(ossSdk.LoadDefaultConfig().
WithRegion("cn-hangzhou").
WithCredentialsProvider(ossCredentials.NewEnvironmentVariableCredentialsProvider()))
files, err := oss.New(client, bucket, 32<<20)
```
上方是互斥初始化片段,实际代码需逐次处理错误。import 路径分别为 `go-omni/storage/local`、`go-omni/storage/s3`、`go-omni/storage/oss`(完整 module 前缀 `git.yuntuo.hk/k2safe/`);`ossSdk` 与 `ossCredentials` 来自 `github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss` 及其 `credentials` 子包。S3 凭据来自 `github.com/minio/minio-go/v7/pkg/credentials`。
统一约定:key 是对象标识,不是磁盘路径;拒绝绝对路径、`..`、空路径段和反斜杠。Put 必须传已知长度,并检查实际读取长度。默认单对象 32 MiB,流式读写;OSS 简单 Put 上限 5 GiB,更大使用独立分片能力。Get 返回的 `Body` 由调用方关闭,读取过程也应有 context 截止。Delete 缺失对象成功;Get 缺失对象返回 `storage.ErrNotFound`。底层客户端/传输连接池由应用管理,不会在单次操作后关闭。
业务需要临时链接时显式依赖额外接口 `storage.Presigner`。S3/OSS 实现 `PresignGet`;Local 用 `storage.NewSigner(files, downloadBaseURL, secret)` 提供 HTTP 下载 Handler。链接 TTL 为 1 秒到 1 小时,私钥/临时凭据可能让云链接提前失效。签发链接前必须完成业务授权;对象 key 及 bucket 本身不是租户权限。签名链接不要写日志。受限直传和云端分片现已提供可选接口,见 [上传与续传说明](storage/README.md)。生命周期、ACL、CDN 自定义域名和版本管理仍由平台/部署管理。
R2 实现 S3 的子集,配置入口使用标准账户 endpoint;法域专属 endpoint 可显式配置。兼容性依据:[R2 S3 API](https://developers.cloudflare.com/r2/api/s3/api/)、[R2 签名链接](https://developers.cloudflare.com/r2/api/s3/presigned-urls/)。OSS 使用其[官方 Go SDK v2](https://www.alibabacloud.com/help/en/oss/developer-reference/manual-for-go-sdk-v2/),没有假定全部 S3 行为与 OSS 等价。
验证区分:Local 契约、OSS SDK HTTP fixture、S3 SDK fixture 和 R2 签名参数属于本地测试;Linux runner 再跑真实 MinIO 上传/读取/删除与签名拒绝。**MinIO 通过不能代替 OSS/R2 实网验证。**迁移存储平台还需要复制已有对象和调整部署权限,不只是切配置。
## 可靠队列
```go
backend, err := queueRedis.New(redisClient, queueRedis.Config{
Namespace: "serviceA:jobs:v1", MaxRecords: 1000,
MaxPayloadBytes: 64 << 10, DedupTTL: 24*time.Hour,
})
consumer, err := queueRedis.NewConsumer(backend, queueRedis.ConsumerConfig{
Workers: 4, MaxAttempts: 3,
TaskTimeout: 30*time.Second, VisibilityTimeout: time.Minute,
})
// ID 与内容在业务端持久化;超时后复用原 ID 和原内容。
receipt, err := backend.Publish(ctx, queue.Message{
ID: operationID, Name: "generateReport", Payload: payload,
})
// 在服务生命周期中运行;返回错误须让监控/进程管理感知。
err = consumer.Run(serviceContext, func(ctx context.Context, item queue.Delivery) error {
return generateReportIdempotently(ctx, item.ID, item.Payload)
})
```
业务依赖 `queue.Publisher` / `queue.Consumer`,Redis 类型留在组合处。receipt 表示受理,不代表业务成功。普通 error/panic 按配置重试,`queue.Reject(code)` 保留为失败消息;超过次数同样保留。`DeadLetters(after, limit)` 是有界分页,`DiscardDead` 仅在人工核对后删除匹配的失败消息,并继续保留一段提交去重窗口。错误正文不写入失败记录。需要再次处理时由业务决定新操作身份及去重策略,框架不自动无限重投。
一个 namespace 使用一个受管 consumer group;多个 consumer 可竞争消费同一队列。配置一致性检查拒绝不同的容量/去重策略和执行超时/重试策略混用。确认检查当前 pending owner,旧执行不能确认新执行;但不能阻止旧处理器已发出的外部副作用,因此仍必须业务幂等。处理器需响应 context;Go 无法强杀不配合的函数,超过 shutdownTimeout 返回错误时,最多 Workers 个处理器可能尚未退出。
容量包含待处理、失败及未过期的已完成身份,满额拒绝新 ID,保留原 ID 重报回执。完成/人工丢弃后的身份按 DedupTTL 过期;未完成/失败消息不自动清除,不用 MAXLEN 静默裁掉未消费消息。Redis 需启用合适的 AOF/复制策略与 noeviction;本批验证的是投递/接管协议,未证明 Redis 主从故障切换后零丢失。异常进程留下的无 pending consumer 元数据可在运维核实后清理,不能用 DELCONSUMER 丢掉尚未接管的 pending。
`task.Runner` 仍适用于本进程短任务和定时轮询,重启会丢失;需要持久交付时使用 `queue`。这里没有分布式 Cron、业务工作流、事务 Outbox 或 exactly-once 承诺。
## 认证与授权
`auth.NewJWT` 要求固定 issuer/audience 和可信 kid→公钥列表;只开放 RS256 / EdDSA,必须有 exp/sub,检查有效期、nbf 与 iat。`ReplaceKeys` 原子替换列表,轮换期可同时保留新旧 kid。JWT 签发、私钥托管、登录口令验证留给身份服务。
`auth.NewSessions(cacheStore, "sessions:", time.Hour)` 签发随机 opaque bearer token;存储仅使用 token 的 SHA-256 键,可 `Revoke`。Redis 缓存可共享 Session,内存缓存只在单实例生效且可能因容量淘汰提前登出。不要把 Session token 当普通缓存值写日志。
`RequireAuth(authenticator)` 在前,`RequirePermission(policy, action, resource)` 在后。Policy 接收已验证 Principal,业务自行校验租户、角色、资源归属。示例 `examples/platform` 验证了无身份 401、角色不足 403 和授权写入。
## 配置与日志热更新
`config.LoadFile(path)` 使用默认值→文件字段显式引用→类型绑定→校验;没有自动环境覆盖。用 `${NAME}` 或 `${NAME:-fallback}` 指定来源,完整规则与迁移见 [CONFIG.md](CONFIG.md)。
```yaml
address: '127.0.0.1:8080'
transport: native
shutdownTimeout: 10s
log:
level: info
format: json
```
先按配置创建 `logx.New(cfg.Log)`,再 `config.NewReloader(path, logger)`。显式调用 `Reload()` 或运行 `Watch(ctx, time.Second, onError)`;后者阻塞,生命周期由应用负责。只有 `log.level` 允许变化,校验失败或静态字段变化保留原快照与日志级别。子 logger 共享动态级别。替换文件时建议使用临时文件 + rename。
## 熔断与观测
为每个上游创建 `resilience.New(Config{MaxConcurrent: 16, FailureThreshold: 5})`;通用调用用 `Do`,HTTP 用 `resilience.Transport{Base: transport, Breaker: breaker}`。HTTP 槽位覆盖响应体读取,必须关闭 body;网络错误/5xx 计失败,冷却后只放行一个探针。不要对效果未知的写操作自行换身份重试。
`telemetry.New` 接受 OTel `sdktrace.SpanExporter` 和 `sdkmetric.Reader`;可注入官方 OTLP exporter、PeriodicReader 或测试用 exporter,基础包不强制选择 collector 协议。`app.Use(runtime.Middleware())` 采集入站,`telemetry.Transport` 采集出站并传播 W3C trace context;Redis 使用 `client.AddHook(telemetry.RedisHook{Runtime: runtime})`,SQL 使用 `telemetry.SQL{DB: db, Runtime: runtime}`。业务操作/事务可用 `runtime.Do(ctx, "fixedOperationName", fn)` 包裹。
创建者负责在停止请求/Worker 后调用 `runtime.Shutdown(ctx)` 刷新并关闭 exporter/reader。指标按注册路由模板和固定方法分类;自动采集不记录 URL query、SQL 正文、Redis key、token 或业务载荷。业务自定义 span 名称同样应固定,避免用用户 ID 或对象 key。
## 分布式限流与幂等
`middleware/redis.NewRateLimiter(client, namespace, cfg)` 跨副本使用 Redis 时间与原子令牌桶;满额/存储失败返回 503,额度不足返回 429 和 Retry-After。`NewIdempotencyStore` 注入 `middleware.IdempotencyConfig.Store`;Scope 必须来自认证后的租户/用户,先认证再幂等。不同请求内容复用同一身份返回 409,已完成请求回放原响应和 requestId。
claim 在开始业务前持久保存,完成按 owner 原子检查。进程崩溃留下的 running claim 不自动按租约过期;返回 409 等待核查,`Resolve` 仅由运维核对后显式调用。已保存的完成/失败记录按配置 TTL 清理,TTL 过后不再提供业务去重保证。失败结果、含 Set-Cookie/no-store 的响应不用于成功回放。共享 Redis 需要 noeviction 和独立 namespace,数据丢失时不存在框架层面的重复执行保证。
## 迁移、审计与分页
迁移命名:`0001_createUsers.up.sql`,可附 `.down.sql`,但第一版 CLI 只执行 up。`go-omni migrate --dir migrations` 默认打印版本/方向/SHA-256 清单,不连接数据库;部署步骤显式传 `--apply` 才读取 `GO_OMNI_DB_DSN` 执行。迁移使用独立连接与数据库锁;MySQL DDL 可能部分提交,失败保留 dirty,禁止自动 force 或回滚。当前基于 golang-migrate,数据库版本表不保存每个历史文件校验和,部署系统应保留并校对预览清单。取消在当前有界 SQL 操作结束后停止,重试需新建 Runner。
审计:`audit.New(sink, "approvedField")` 建白名单,再 `Record(ctx, Event{ActorID, Action, Resource, Outcome, Fields})`;补入服务端时间、requestId/traceId,只保留明确允许的属性,Sink 失败原样返回。JSONSink 不保证与业务提交原子一致,需要这种语义时由业务在同一数据库事务内提供 Sink/Outbox。
分页:`pagination.Parse(request.URL.Query(), pagination.Config{})` 默认 page=1/pageSize=20,上限 100,最大 offset=1,000,000;拒绝重复参数、溢出和越界。`pagination.NewResult(request, items, total)` 输出 items/page/pageSize/total/totalPages,空列表为 `[]`。查询与租户过滤仍由 Repository 负责。
## 验证入口
```sh
go test -race ./...
go vet ./...
go build ./...
python3 scripts/verifyModules.py --advanced --goproxy https://goproxy.cn,direct --output performance/advancedLinux.json
```
Linux runner 顺序运行编译、Redis、MinIO、MySQL 四个容器,各限 2 CPU、2 GiB、256 pids;数据阶段无外网、无发布端口,不连接现有数据库。原始结果和源码摘要以生成的 JSON/TXT 为准,未提供云凭据的 OSS/R2 不标为实网通过。
本批已通过上述 Linux 验证,源代码提交、98 个文件摘要、服务版本和边界见 [验收记录](performance/advancedAcceptance.md)。

10
AGENTS.md Normal file
View File

@@ -0,0 +1,10 @@
# 开发约定
- 框架保持通用,不内置接入项目的业务类型、字段、错误码或业务规则。
- 2026-09-25 用户要求:每次功能开发完成,必须同步维护根 README.md。README 需要覆盖当前全部公开功能,解释用途、基本用法,并提供可运行示例或链接到 examples 内的完整示例。
- 新增、调整和删除功能都必须同步文档、示例、测试与发布说明。删除功能要明确说明已移除内容、影响范围和迁移步骤,不能继续把历史用法当作当前能力推荐。
- examples 使用通用场景,避免依赖业务项目、私有数据库或线上服务。必要的外部基础设施配置应明确说明。
- 2026-09-25 用户决定:移除 mappings/mappingFiles 及结构映射代码生成机制,改用可选 copyx 工具包。不得恢复另一套 JSON 或 Go 映射声明语言。
- 深拷贝的自定义转换结果不得与输入共享可变引用;发现共享应报错,不能降级成浅拷贝或静默接受。
- 2026-09-26 用户决定:配置文件显式声明固定值或 `${NAME}` / `${NAME:-fallback}`,旧自动环境覆盖、前缀/别名及 env/envDefault 标签模式移除,不得重新引入隐式二次覆盖。项目维护 Config/Default/Validate/文件,生成器维护 Load/main。

333
API.md Normal file
View File

@@ -0,0 +1,333 @@
# API 契约与项目生成
当前生成目录和启动扩展采用新布局,见 [项目维护指南](PROJECT.md)。`@app(defaultResponse: Name)` 在入口选择 App 默认响应。`@body(mode: raw|multipart)` 和 `@produces` 支持自动 Handler,手写 Handler 仅为显式选择的扩展。
首次接入请先看 [README](README.md):包含固定远程版本安装、可运行项目、完整 `.api` 示例和已有服务接入步骤。本页用于查阅语法与生成规则;下方带 `--framework-dir` 的命令用于框架源码开发。
本阶段目标:从 `.api` 定义生成可运行的 Go 项目;定义、绑定、校验与 OpenAPI 保持一致;重复生成不覆盖业务实现;业务代码继续独立于网络层。实现入口是 `cmd/go-omni/apigen`,运行时契约编译器是 `contract`。
v0.14 的 [完整开发指南](DEVELOPMENT.md) 说明响应根类型、`@request`、multipart 字段、注释/格式化、Go 错误目录导出与共享契约。
## 与 go-zero 的对应关系
调研参考了 go-zero 官方的 [API DSL](https://go-zero.dev/reference/api-dsl/)、[参数规则](https://go-zero.dev/reference/api-dsl/parameter/)、[goctl API](https://go-zero.dev/reference/cli-guide/api/) 和 [项目结构](https://go-zero.dev/concepts/project-structure/)。借鉴其声明接口后生成项目的工作方式:统一的类型定义、路由声明、分组、前缀和命名中间件。实现独立编写,不引入 go-zero 或 Gin 运行时依赖。
| 使用体验 | go-omni 实现 |
| --- | --- |
| 新建接口项目 | `go-omni api new <directory>` |
| 按契约生成、更新 | `go-omni api generate --api user.api --dir .` |
| 熟悉的命令别名 | `api go -api user.api -dir .` |
| 校验契约 | `api validate --api user.api` |
| CI 检查生成产物是否过期 | `api generate --api user.api --dir . --check` |
| 业务逻辑目录 | `internal/logic/<group>`,使用标准 context.Context |
| 依赖装配 | `internal/svc` |
| 接口文档 | 自动生成 `docs/openapi.json`,HTTP 端点由 `server.endpoints.openapi` 配置 |
这是明确限定的 go-zero 风格方言,**不是完整 goctl 兼容实现**。不支持的声明和校验标签会在生成前报错。
## 立即试用
源码开发时,从 go-omni 仓库运行,显式引用本地框架:
```sh
go run ./cmd/go-omni api new \
--module example.com/omniDemo --framework-dir . ../omniDemo
cd ../omniDemo
go mod tidy
go run .
```
访问 `http://127.0.0.1:8080/api/greet/Alice` 可得到带 `data.message` 的统一响应。`api new` 的问候示例包含可运行逻辑;自定义契约新生成的 Logic 会明确返回 501,等待业务实现。
用现成的多分组定义生成项目,在 go-omni 仓库执行:
```sh
go run ./cmd/go-omni new --api examples/contracts/user.api \
--module example.com/userService --framework-dir . \
--log-output file --log-format json ../userService
```
`api generate` 在新目录中也会生成完整项目。默认只监听 localhost;`GO_OMNI_HTTP_ADDR` 修改地址,`GO_OMNI_TRANSPORT=native` 切换网络层。配置顺序为 Default() → `etc/config.yaml` 中的显式环境引用 → 类型绑定 → Validate();固定值不再被隐式覆盖。文件日志支持现有轮转配置。远程试用使用与 CLI 一致的固定版本,见 [发布说明](RELEASE.md)。
API 项目现在支持组合数据库、存储和日志:
```sh
go-omni new --api user.api --module example.com/userService \
--database mysql --storage local --log-output file ./userService
```
- `--database none|memory|mysql`:API 默认 `none`,`memory` 是不创建数据库连接的别名;MySQL 生成连接池、配置、事务入口和 readiness 探针。
- `--storage none|local|s3|r2|oss`:默认 none,生成统一 Files / Uploads / Downloads / UploadLinks 依赖;本地签名对象端点为 `/storage/object`。
- `--log-output stdout|stderr|file` 与 `--log-format json|text`:配置和无配置文件启动使用一致默认值。
初次选项记录在 `.goOmni/apiManifest.json`,以后直接 `api generate` 会继承。已有项目通过参数切换选项会报错,因为初始化代码属于业务方;可在新目录生成对照后手动迁移。`--force` 不允许覆盖 API 项目。new 与 api new 统一生成契约项目,不再使用旧式 CRUD 目录。
MySQL 使用官方 `database/mysql` 结构化适配器,生成文件以 `password: "${GO_OMNI_DB_PASSWORD}"` 显式读取密码;其他字段可写固定值或引用,缺失项保留 Default()。存储配置也只读取文件明确引用的变量;API 项目不会额外生成公开的签名发放接口,鉴权和对象权限由业务实现。初始化失败会清理已打开资源,退出清理可重复调用。
HTTP 请求类型不能推导数据库表结构;迁移和 Repository 由业务自己维护。当前目录的完整示例见 [managedService](examples/managedService/README.md)。MySQL、事务审计和鉴权实现可参考 [旧目录用户管理示例](examples/userService/README.md),接入时按 [项目指南](PROJECT.md) 装配到 config/svc/logic。
## 接口定义
```go
syntax = "v1"
type UpdateUserReq {
UserID int64 `path:"userId,range=[1:1000000]"`
Notify bool `form:"notify,default=false"`
Name string `json:"name" binding:"required,max=50"`
Age *int `json:"age,optional,range=[0:150]"`
}
type UserResp {
UserID int64 `json:"userId"`
Name string `json:"name"`
}
@server (
group: user
prefix: /api/v1
)
service user-api {
@doc "Update a user"
@handler updateUser
put /users/:userId (UpdateUserReq) returns (UserResp)
}
```
类型和 Go 字段使用导出命名;JSON、query、path 名称使用 lowerCamelCase;Header 使用 HTTP 标准名称。响应类型定义业务返回值。未配置时使用默认公共体;当前源码支持在 `.api` 中声明公共响应模板,选择字段和结构,并按分组复用或由单接口覆盖,详见 [公共响应体](RESPONSE.md)。
支持:
- 可省略的 `syntax = "v1"`;`info` 中的 title、version 和描述性元数据。
- 单独的 `type X { ... }` 与 `type (...)`;基础标量、指针、嵌套命名类型、切片、字符串键 map。嵌套对象只使用 JSON 字段。
- 相对 `.api` 导入及 `import (...)`,重复导入去重;拒绝循环和未授权的目录越界。跨服务共享须在 `omni.imports.json` 声明 roots,见 [共享契约](DEVELOPMENT.md#两个服务共享契约)。单文件 1 MiB、总计 8 MiB、最多 64 个文件,限制导入/类型深度及 schema 展开规模。
- 同一 service 名称的多个块;`@server` 的 group、prefix、middleware、auth、errors、response,group 可以写 `admin/user`。
- 每个路由必须有 `@handler`;可选单行/多行 `@doc`、`@auth`、`@errors`、`@response`;GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS。
- 可省略请求或响应;有响应默认 200,无响应默认 204。Omni 扩展 `@status 201`、`@status 202`;204 不允许响应类型,HEAD 不允许响应体,GET/HEAD 不允许 JSON 请求体。
- 每个请求字段只使用一个来源:`json`、`path`、`form`、`header`,或声明式上传的 `multipart`、`multipartJSON`、`file`。`form` 始终表示 URL query;query 切片用重复键,header/path 使用标量。上传示例与限额见 [声明式 multipart](DEVELOPMENT.md#声明式-multipart)。
- 来源标签中的 `optional` / `omitempty`、标量 `default`、标量 `options=a|b`、数值 `range=[1:100]`(支持开区间和单边界)。
- `binding` 的 `required`、`min`、`max`、`len`、`email`。长度按 Unicode 字符或切片元素数;范围边界必须有限且位于 ±2^53 内。
暂不支持匿名嵌入、别名、递归类型、未声明的外部 Go 类型、未通过 scalar 显式接入的自定义编解码、`[]byte`、泛型、`json:",string"`、任意 validator 标签、go-zero 的 JWT/签名/SSE 声明,以及客户端 SDK 生成。认证验证通过声明关联的命名中间件实现。未列出的语法会明确报错。
业务错误、具名鉴权、多行文档及下述开发命令从 CLI `v0.7.0-preview.1` 提供。公共响应模板从 `v0.8.0-preview` 提供,CLI 与业务项目中的框架依赖需一起升级。
## 业务错误:声明、代码和文档共用一份定义
**错误目录也可以只在 Go 中维护。** 从 `v0.11.1-preview` 起,生成路由没有声明错误列表时,继承 `omni.WithErrors(catalog)` 注册的 App 目录;`.api` 继续定义接口和返回格式,无需重复登记错误。全局中间件提前返回同样适用。显式引用 `.api` 错误时才使用路由独立目录,规则见 [目录继承](errors/README.md)。CLI 不执行项目 Go 配置;可显式用 `errors.ExportCatalog` 导出 `omni.errors.json`,供文档合并同一目录,见 [导出步骤](DEVELOPMENT.md#字段文档格式化go-错误目录导出)。
从 `v0.10.0-preview` 起,推荐用 `errorCatalog` 同时定义逻辑标识、对外业务码、提示和 HTTP 状态:
```text
errorCatalog CommonErrors (
defaultStatus: 200
errors: {
"UsernameInvalid": {"key": "USERNAME_INVALID", "code": 400002, "message": "用户名格式不正确"},
"PasswordInvalid": {"key": "PASSWORD_INVALID", "code": 400002, "message": "密码格式不正确"},
"Forbidden": {"key": "FORBIDDEN", "status": 403, "code": 403001, "message": "没有权限"}
}
)
```
对象键是生成函数名;`key` 是唯一逻辑标识,允许项目自己的命名;`code` 是对外业务码,可以重复。数字码需搭配 `codeType: int32` 或 `int64` 的公共响应;字符串码搭配 `string`。目录不规定返回体字段。
省略 `defaultStatus` 时为 400;设为 200 时,只作用于目录里未单独写 `status` 的条目。生成路由自动绑定它实际声明的错误目录,在 App 中间件之前生效。业务只调用 `apierrors.UsernameInvalid()`;无需再维护 `codes` / `messages`,也无需修改 main。手写路由可复用生成的 `apierrors.Catalog()`。
同一 HTTP 200 下,OpenAPI 用 `anyOf` 保留成功 DTO 和错误体,日志与指标仍识别业务失败。完整语法、路由作用域与手写项目配置见 [统一错误目录](errors/README.md),可运行契约见 [errors.api](examples/contracts/errors.api)。
从 `v0.11.0-preview` 起,目录条目支持 `params` 有序列表与 `{name}` 提示模板:
```json
"TextTooLong": {
"key": "TEXT_TOO_LONG", "code": 400002,
"message": "{label}不能超过{max}个字符",
"params": [
{"name": "label", "type": "string", "example": "企业名称"},
{"name": "max", "type": "int", "example": 100}
]
}
```
生成 `apierrors.TextTooLong(label string, max int)`,返回时 Message 已填好;固定提示保持无参数函数。声明顺序决定参数顺序。模板校验、运行时缺参/类型错误、转义及非 HTTP 使用规则见 [提示模板参数](errors/README.md)。OpenAPI 的错误目录保留参数定义,响应示例使用具体文案。
原有声明继续支持:
```go
error (
Unauthenticated 401 "UNAUTHENTICATED" "authentication required"
Forbidden 403 "FORBIDDEN" "permission denied"
UserNotFound 404 "USER_NOT_FOUND" "user not found"
EmailTaken 409 "EMAIL_TAKEN" "email already in use"
)
```
也可写单条 `error EmailTaken 409 "EMAIL_TAKEN" "email already in use"`,或把错误目录放进相对导入的 `errors.api`。
名称用 UpperCamelCase,HTTP 状态允许 200–599 中可携带响应体的最终状态(排除 204/205/304),旧声明的逻辑错误码用大写字母/数字/下划线且全局唯一,message 是可公开返回的稳定提示。`Catalog` 为生成函数保留名称。
`@server (errors: Unauthenticated)` 声明组级错误;路由 `@errors UserNotFound, EmailTaken` 追加到组级列表并去重。
`@errors none` 清空 `.api` 分组继承的错误列表,随后按未设置路由目录处理,继承 App 错误目录。未声明、重复声明或生成符号冲突会报告源码位置。
生成 `internal/apierrors/errorsGen.go`:
```go
return nil, apierrors.EmailTaken()
// 可用 apierrors.CodeEmailTaken 引用同一个错误码。
```
每次调用返回新错误,避免并发修改共享错误对象。生成器不会自动把任意数据库错误转成业务错误;由 Service/适配层调用这些函数。
内部 Cause 可能进入访问日志,敏感驱动报错应像 userService 示例一样先归类再转换。
OpenAPI 对每个状态按选定的响应模板生成结构和命名 examples,同一 409 下多个错误不会相互覆盖;平铺模式的纯业务状态还列出 code enum。
默认框架错误状态与自动添加的鉴权 401 保留开放的 code 字段,容纳校验器/中间件实际错误。
`x-omni-errors` 提供错误目录与路由关联。目录解析已定义的错误,不自动推断数据库或其他未声明错误的业务含义,也不穷举所有运行时错误。
## 鉴权:声明与路由注册一起变化
```go
auth UserAuth (
type: bearer
middleware: Auth
description: "Application access token"
)
@server (
group: user
prefix: /api/v1
auth: UserAuth
errors: Unauthenticated
)
service user-api {
@errors Forbidden, EmailTaken
@handler createUser
@status 201
post /users (CreateUserReq) returns (UserResp)
@auth none
@errors none
@handler publicStatus
get /status
}
```
`auth` 是具名契约,`middleware` 指向实际 hook;生成器把它放在普通 middleware 前面执行,并在注册任何路由前检查 hook 非空。
新 hook 未实现时返回 501,已有手写 hook 保留。需要在 hook 中验证凭证、失效时间、签发者、权限等;声明不会自动实现登录或 JWT 校验。
- `type: bearer`:对应 HTTP Bearer,可选 `bearerFormat: JWT`,这只是文档提示。
- `type: apiKey`:必须同时声明 `in: header` 或 `in: cookie` 和 `name`,例如 `X-API-Key` 或 `sessionId`。
- `auth: UserAuth` 可在组级使用;路由 `@auth OtherAuth` 替换,`@auth none` 撤销继承的声明鉴权。普通 middleware 继续保留。
- 不要再把该 auth 的 hook 重复写进普通 middleware。不支持 query token、多个方案 AND/OR、OAuth2 scopes 或隐含认证策略。
- 被保护路由自动描述 401;403 和具体错误码由 `@errors` 明确声明。未声明 auth 的旧项目仍按其原有 middleware 执行。
文档生成 `components.securitySchemes` 与逐接口 `security`;显式公共接口使用空数组。
结构遵循 [OpenAPI 3.0.3 Security Requirement](https://spec.openapis.org/oas/v3.0.3.html#security-requirement-object)。
## 文档和日常开发命令
保留单行 `@doc "摘要"`,也可以:
```go
@doc (
summary: "Create a user"
description: "Writes user data and audit in the same transaction"
tags: users, administration
deprecated: false
)
```
未设置 tags 时使用 group 分组。`info` 的 description/desc 也会进入 OpenAPI;两者不能同时声明。
在生成项目目录内,不必重复输入 API 路径:
```sh
go-omni api validate # 发现 manifest 中的入口并校验
go-omni api inspect # 方法、路径、Handler、鉴权与业务错误总览
go-omni api generate --dry-run # 预览文件变化、契约变更和 Logic 提示,完全不写文件
go-omni api generate # 实际生成,保留手写业务
go-omni api generate --check # 只读检查产物漂移及明确的 Logic 签名错误,可用于 CI
go-omni api export > openapi.json # 独立导出纯 JSON,无需启动服务
go test ./...
```
`--dry-run` 和 `--check` 不能组合。新项目预览也不创建目录;预览不是审批或锁定,实际生成会重新检查文件状态。
契约变化会定位到相关 Logic 文件,涵盖嵌套 DTO、原始 body 和流式返回。生成后签名不一致的问题仍可由 `--check` 检出;项目类型别名/build tags 等不确定情况提示 REVIEW,最终用 `go test ./...` 验证。详见 [契约变更提示](PROJECT.md#契约变更提示)。
在项目外可显式传 `--api`;生成时传 `--dir` 可定位其他项目。不会猜测多个 .api 中哪个是入口。
路由总览的 `(undeclared)` 仅表示没有声明 auth;旧式中间件仍可能做认证。
外发文档可用 export;项目内受生成器管理的 `docs/openapi.json` 请通过 generate 更新,以同步文件哈希。
## 迁移扩展
- 动态 JSON 字段 `any` / `interface{}`,包含切片和 map;外围契约仍严格。
- `scalar Name (...)` 显式引用项目类型,声明 JSON 输入/输出类型及文本绑定;支持已有 JSON/Text 转换方法。
- `@body (mode: json/raw/multipart; maxBytes: ...)`:按路由限制请求体。
- `@binding custom` 生成保留的绑定 hook;`@handlerMode manual` 生成保留的整个 Handler,仅用于自定义协议。raw 与未声明上传字段的 multipart 将受限流交给 Logic;声明了上传字段时则交给 Logic 已校验的 DTO。
- `@produces "text/plain"` 等声明无 DTO 成功输出;自动模式的 Logic 返回 `*httpx.Stream`,Handler 负责流式发送和关闭。默认状态 200。manual 模式保留项目自己的输出实现。
- 自动 multipart Handler 检查未读部分和结束边界;损坏/截断默认返回 400,超限为 413。公共状态、码和文案仍可用项目错误目录配置,见 [流式接入与错误处理](PROJECT.md#自动上传回调与下载)。
迁移扩展起始于 v0.9.0,原有手写扩展说明见 [迁移扩展指南](MIGRATION.md)。当前根目录入口和自动流式 Handler 要求 CLI 与框架依赖一起使用 v0.14.0-preview;版本说明和新目录维护规则见 [项目指南](PROJECT.md)。不兼容全部 goctl 语法。
## 参数语义
无 optional/default 的字段按“是否出现”判断必填,`false`、`0`、`""` 是已提供的值。额外声明 `binding:"required"` 才会拒绝标量零值。默认值只在字段缺失时使用,不替换显式的空值。
业务需要区分“没传”和“传了零值”时使用 `*T` 加 optional。指针允许 JSON null;普通非指针字段拒绝 null(动态 `any` 允许);`binding:"required"` 拒绝指针 null。缺失与 null 都会得到 nil,本版没有三态 Nullable 类型。响应端的 `optional` 不等于 Go 编码器的 `omitempty`;需要省略零值时显式使用 `omitempty`。
普通 JSON 请求体必须是一个 object,Content-Type 为 application/json;响应根类型另支持数组、nullable 对象和 null。默认最多 1 MiB,包含 chunked/未知长度和尾部空白;超限返回 413。重复 JSON key、未知字段、多个 JSON 值、标量 query/header 重复值、格式和约束错误返回 400;错误 Content-Type 返回 415。失败不会部分修改目标 DTO;错误详情只保留在内部 cause,公共响应不回显原始参数。
`contract.Binder` 在服务启动时编译,随后并发复用,不会每次请求重新分析标签。CLI 用相同编译器产生文档,因此来源、必填、默认、nullable、枚举、范围和长度具有同一来源。响应类型和约束由业务实现遵守,不在每次响应上再次校验;嵌套的非 nullable 集合请返回空集合而不是 nil,根 `returns ([]Item)` 会把 nil slice 规范为 `[]`。
## 生成目录和业务边界
```text
api/ # 开发者维护的契约及导入
main.go # 启动、日志、优雅退出
etc/config.yaml
internal/config/
internal/svc/ # 依赖初始化和释放
internal/types/*Gen.go
internal/apierrors/errorsGen.go # 声明错误后生成
internal/handler/routesGen.go
internal/handler/openapiGen.go
internal/handler/<group>/handlersGen.go
internal/logic/<group>/logic.go
internal/logic/<group>/<operation>.go
internal/repository/<group>/repository.go
internal/middleware/registryGen.go
internal/middleware/<name>.go
docs/openapi.json
.goOmni/apiManifest.json
```
Logic 方法接收标准 `context.Context` 与生成的请求 DTO;Handler 调用 Logic 并完成响应包装。网络适配器仍经 Omni/httpx 接入。Repository 是业务所有的扩展位置,不自动生成虚构 CRUD 查询。
命名中间件按声明顺序组合,第一个在最外层。新 hook 初始返回 501,必须实现后才放行;registry 自动维护,hook 文件只创建一次。缺失 hook 在注册任何路由前报错。文档通过 `x-omni-middleware` 标出命名 hook,不把名称误当成已实现的认证协议。
## 安全更新与 CI
在生成项目中安装/使用与框架版本相符的 go-omni CLI:
```sh
go-omni api validate --api api/user.api
go-omni api generate --api api/user.api --dir .
go-omni api generate --api api/user.api --dir . --check
go test ./...
```
`main.go`、`Gen.go` 和 OpenAPI 由生成器管理,manifest 保存其 SHA-256。手动修改后更新会报冲突。Logic、Repository、配置、依赖装配和中间件实现按字节保留;只补缺失文件。删除接口/分组时只移除旧的生成文件,保留业务代码;已删除类型仍被旧业务引用时,需要明确迁移该业务文件,编译器会提示。
`--check` 只读,检查产物是否过期,以及所需骨架是否缺失。契约复制到项目后,请编辑项目内的 API;外部定义与已存在副本不一致时会拒绝覆盖。一个项目由一个 API 入口管理,可以通过导入拆分文件。
生成器拒绝输出路径穿越、受管理路径上的符号链接、大小写路径冲突和无所有权的覆盖。生成前统一校验/格式化,锁文件防止两个生成器同时修改;文件以原子替换写入,普通写入失败会回滚。本版不是整个目录的崩溃原子事务:进程被强杀或机器断电后,应按 Git 恢复生成产物和 manifest,再重试。请将 manifest 与生成产物一并提交,也不要在生成过程中编辑这些文件。
生成器不自动安装依赖、不访问数据库、不推送仓库、不替业务实现权限规则。
## 验证
```sh
go test -race -p=2 ./contract ./openapi ./cmd/go-omni/...
python3 scripts/verifyManagedProject.py
```
Go 测试覆盖契约拒绝规则、多源绑定、零值/缺失、默认/范围/嵌套集合、文档约束、幂等更新、只读检查、业务保留、删除路由、路径边界及生成项目编译。Python 验收会在临时目录生成项目,分别启动 nethttp/native,验证真实 HTTP 请求、中间件、日志 requestId 和优雅退出;结束后清理自己创建的进程和临时文件。结果记录在 `performance/managedProjectLocal.json`;完整记录见 [第一版验收](performance/managedProjectAcceptance.md)。
内置健康与 OpenAPI 路由可通过配置禁用或修改路径,`.api` 不再固定保留 `/health/live`、`/health/ready`、`/openapi.json`。启用的内置路径与业务 GET 路由重叠时,生成的 NewApp 在监听前返回配置错误;禁用后可以由 `.api` 自己维护这些路径。见 [内置接口配置](PROJECT.md#配置内置接口)。

26
BUSINESS_DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,26 @@
# 通用业务开发能力完善计划
本轮目标:业务维护契约、配置、依赖装配与业务逻辑,框架持续维护 HTTP 接入、契约校验、通用运行期与文档。按实际生成项目和可删除的业务适配点验收,不写入 eTax 业务规则。
## 实施与验收清单
- [x] 运行期:资源登记、逆序释放、幂等关闭、失败回滚、故障原因保留归框架;生成入口与项目依赖初始化边界明确。多 App 日志隔离、标准 log.Logger 适配、可信请求元数据与依赖中间件示例。
- [x] 配置:Default()→文件显式环境引用→类型绑定→Validate;嵌套、duration、slice、显式零值、必填/备用值、转义与安全诊断;日志无副作用完整校验。
- [x] 数据库:官方 MySQL 结构化配置/TLS/驱动日志适配,继续复用 OpenConnector 和唯一连接池;独立启动检查、失败清理、生成器接入;不内置版本/表结构/业务错误策略。
- [x] 响应契约:数组、可空对象、成功 null、独立 HTTP 204;Logic/Handler/OpenAPI/漂移检查一致。
- [x] 请求契约:未知 query、JSON UTF-8/charset、无 Body/必填空对象/可选 Body,App 默认与路由覆盖;scalar/any/数组/大小限制一致,普通 JSON 只绑定一次。
- [x] 上传:声明文件/文本/JSON 字段,重复/未知/必填/单字段/单文件/总量限制;有界暂存与清理,调用 Logic 前完成普通表单校验;保留流式扩展与 Complete 约定。
- [x] 文档工具:字段注释、example/枚举描述进入 DTO 与 OpenAPI;保注释、幂等格式化及 check;显式 Go 错误目录元数据导出与文档合并,不自动执行项目代码。
- [x] 共享契约:受控 roots/摘要、循环/深度/文件数/路径保护;分源文件 DTO、多个独立 module 的工作区检查;版本提示与基础模板升级预览,保留业务文件。
- [x] 集成:实际生成项目验证新能力、连续生成、业务文件保留、失败清理、nethttp/native 请求与退出;现有错误继承/参数/上传下载回归。
- [x] 最终回归:全量 race(含生成器集成)、vet、build、格式化/链接检查;使用说明、迁移步骤及验收报告完整,发布后远程无 replace 验收。
已发布 `v0.14.0-preview`。逐项验收、可替换的业务适配点与远程报告见 [验收记录](reports/businessDevelopmentAcceptance.md);接入和升级见 [开发指南](DEVELOPMENT.md)。
## 核心约定
响应与错误码由项目配置。字段与请求策略由契约表达;基础设施采用可选适配器,保留标准 Connector。所有业务依赖在请求排空后关闭;框架不重试写操作、不自动迁移业务表、不关闭借用资源。
普通 multipart 为保证进入 Logic 前完成校验,以有界复制暂存到临时文件;框架清理自己创建的资源。纯流式路径明确由调用方在提交持久副作用前完成输入检查。
不修改 eTax/ePay 业务,不连接既有业务服务器。真实基础设施如需验证,只使用本轮隔离资源。验收报告明确区分单元测试、生成消费者测试与真实服务测试。

View File

@@ -1,10 +1,218 @@
# Changelog
## v0.27.0-preview(2026-09-29)
- HTTP 配置新增 defaultMaxBodyBytes、maxBodyBytes、shutdownCancelTimeout;三种内置服务统一默认/路由/硬请求体限额,配置冲突在监听前报告完整路由与限额。native/HTTP2 不再以隐含 8 MiB 阻挡已放宽的 App 上传路由。
- App 在所有服务入口跟踪完整请求处理链;关闭连接后有界等待处理器实际退出。超时仍有请求则返回 ErrResourcesRetained,跳过 App 清理;Runtime.Finish 继续停止后台工作但保留依赖,生成入口保留日志。
- native 提前拒绝上传后有界半关闭连接,减少 TCP reset 吞掉错误响应;业务 HTTP 200 策略不再触发对被拒绝请求体的继续读取。
- HTTP/2 Shutdown 同时等待连接和独立处理器,修复客户端断开后误判完成。增加真实 TLS/流式上传、取消收尾、拒绝晚到请求、资源保留和生成消费者回归,以及 httpSafety 示例。
- 迁移:nethttp 手写普通路由新增默认 8 MiB 请求体上限,大请求显式覆盖;生成契约默认 1 MiB 保持。HTTP 停机多一个默认 5s 取消收尾窗口;外部宿主须将运行错误传给 Runtime.Finish,并遵守资源保留信号。
## v0.26.0-preview(2026-09-29)
- 新增独立 i18n:不可变命名空间词典、源/默认/启用语言、BCP 47 匹配、显式回退、参数/条件/基数复数和有界资源加载;不依赖 HTTP、业务或数据库。
- Go/浏览器共用固定 CLDR 46.1 复数规则;JSON 资源版本、类型导出和按命名空间选择的语言包独立于业务错误格式。
- 新增可选 httpi18n/errori18n 适配;错误 translation 元数据、PublicMessage 展示入口,保留原业务码/状态/失败语义/原始原因和首次幂等响应。
- 新增 i18n check/export/types/extract;静态与动态键诊断、未知字段/重复定义/参数校验、显式覆盖;输入只读,不执行任意项目 Go。
- 新增无前端框架依赖的浏览器模块、按需加载/切换竞态/失败保留、CLI/HTTP/表单示例及通用验收。旧项目默认行为不变。
- 新 translation 生成代码要求 v0.26.0-preview 或具备能力的本地源码,未使用该元数据的模板最低版本保持 v0.22。
- 修复 JSON 对象/数组结束定界符读取失败时丢失原始错误的问题,保留请求体限额的 HTTP 413;共享 Go 目录测试工程改用声明支持的 Go 1.22。
## v0.25.0-preview(2026-09-26)
- database.InTx 新增 TxError、TxStage、TxOutcome,区分开启/回调/提交失败以及未执行回调、已确认回滚、结果不确定;原签名保留。
- 回调失败后只有显式回滚返回成功才标记 rolledBack;提交报错一律保守返回 unknown,不根据断网/超时或 ErrTxDone 推断回滚。不自动重试事务,不处理文件清理等业务决策。
- Err/RollbackErr 分开保留,errors.As/Is 可遍历两条原因链;业务错误目录和公开响应行为保持。nil context 接受,nil 回调在开启前报错,panic/Goexit 保持传播并尝试回滚。
- 新增阶段/取消竞争/确认丢失/并发/公开响应回归、真实 MySQL 驱动边界故障验收、性能比较和 examples/transactions;同步 README 与 database 指南。
- 错误现在有类型包装;直接错误相等、直接类型断言和错误文本依赖需改为 errors.Is/As。标准库异步回滚不公开的底层原因无法还原,结果保持 unknown;不改变 GORM 自己的 Transaction。
## v0.24.0-preview(2026-09-26)
- 新增可选 cors 包和 server.cors 配置;生成项目直接加载,手写项目使用 omni.WithCORS,独立 HTTP 服务可使用 Policy.Wrap。默认关闭,无新增第三方依赖。
- 精确来源白名单、方法/请求头、暴露头、凭据和预检缓存可配置;拒绝凭据与通配来源混用、非法来源/头及歧义的重复请求头,校验错误定位具体字段。
- 在路由和鉴权之前处理预检;实际请求仍执行原有中间件,统一响应、404/405、上传下载和流式能力保持。nethttp/native/HTTP2 共用策略。
- 补充真实生成项目、TLS 三种 transport、20 项真实浏览器检查、race/fuzz/性能基准;同步 README、配置指南、cors 指南及 examples/cors。
- 没有移除现有功能或修改默认跨域行为。代理已负责 CORS 的项目保持关闭,避免重复头;高级调用 Router().ServeHTTP 不经过应用外层 CORS。
## v0.23.0-preview(2026-09-26)
- MySQL/连接池、日志、HTTP 客户端、生命周期和请求策略返回字段级安全诊断,配置加载器保留文件/变量/位置与原始错误链。
- httpc.JSON 新增 WithSuccessStatuses,单次调用可限制允许的 2xx,默认兼容,拒绝响应继续关闭和有界回收。
- copyx.Check[S,T] / Copier.Check 预检嵌套类型与现有规则;修正空集合隐藏类型不匹配,动态接口和 converter 的具体行为继续运行时验证。
- logx.Config.TimeZone 绑定 Logger 实例,JSON/text/子 Logger/标准日志适配一致;不修改 time.Local 或业务时间属性。
- 无新映射文件、强制模块或第三方依赖;更新 README、各包指南和 developerTools 可运行示例。错误文本更精确,空集合中的确定类型错误现在会被拒绝。
## v0.22.0-preview(2026-09-26)
- 文件显式环境引用与备用值,字符串组合/转义,安全有界展开和类型绑定;变量值原样插入、不递归处理。
- 移除隐式环境覆盖、EnvOptions/前缀/别名及 env/envDefault;数据库统一使用文件加载,不再单独扫描环境。
- 生成 main/Load 新增 -check-config,精简配置文件,保留开发者扩展;错误字段建议和迁移检查。
- README、CONFIG、数据库/HTTP 客户端指南及示例同步新规则;配置中的响应模板需要 $$ 转义,.api 不变。
## v0.21.0-preview(2026-09-26)
- httpc 可配置环境/直连/固定代理、常用连接阶段超时和独立空闲池参数。
- JSON 新增逐请求 Header 快照与响应大小选项,保持有界读取、关闭和认证隔离。
- 成功请求少复制和退避计算,丢弃响应流式处理,错误体有界回收以改善连接复用。
- 增加真实本地代理、超时、连接限制/回收、并发、日志脱敏、gzip、fuzz 和修改前后基准。
- JSON 自定义方法接口签名需同步 variadic 参数;借用 Transport 的参数混配现在报错。详见 httpc/README.md。
## v0.20.0-preview(2026-09-26)
- 生成项目配置加载入口持续由生成器维护;保留业务字段、默认值、校验与 NewLoader 扩展文件。
- config.LoadProject 统一 JSON/YAML/XML、环境覆盖和校验;提供实例级 Decoder 注册。
- 文件/字段/环境/行列的安全诊断,Invalid/AtField 与原始错误链。
- 增加格式等价、精度、null/空数组、并发、资源限制、默认值隔离、真实生成项目与性能验收。
- 旧手写 Load 必须显式迁移;低层 API 保留。固定数组严格长度、YAML alias/merge/custom tag 拒绝。用法及限制见 CONFIG.md。
## v0.19.0-preview(2026-09-25)
- 新增通用可选 copyx:构造器、浅/深拷贝、精确类型转换、字段规则、nil/零值策略、并发复用。
- 深拷贝拒绝自定义结果共享源可变引用;原子提交目标、保留错误链、限制遍历及估算分配。
- 移除 JSON 结构映射配置与生成;提供明确迁移诊断,保留公共字段组合等其他生成能力。
- 同步 README 全部能力索引、COPY.md、可运行示例和开发文档约定。
- 验收与不兼容迁移见 [发布说明](RELEASE.md)、[copyx 验收](reports/copyxAcceptance.md)。v0.14–v0.18 的完整变更保留在发布说明历史段落。
## v0.13.0-preview(2026-09-24)
- 新增通用 lifecycle.Controller,svc.RequestShutdown 支持依赖主动通知正常停止或致命故障,通知不阻塞,首个故障原因保留;生成入口排空 HTTP 后关闭依赖与日志,故障退出码为 1。
- App.RunContext、ServeContext、RunWith 保留自定义 context 取消原因;正常信号取消仍正常返回。
- 健康检查默认使用 App 响应模板和错误目录,支持 raw JSON;HTTP 状态保持 200/503,避免业务 HTTP 200 策略掩盖未就绪。内部探针原因仅供日志,失败状态继续记录到日志/指标。
- 生成项目通过 server.endpoints 配置 liveness/readiness/openapi 的开关与路径;全局中间件继续生效,OpenAPI 保持原始规范。禁用端点不保留路径,启用路径与业务 GET/参数/通配路由冲突时在监听前报错。
- 生成器最低框架版本更新到 v0.13.0-preview;manifest v2 和业务文件保持。v0.12 项目需要显式升级其 svc 扩展,main/Handler 由重新生成更新。
- 增加故障退出、排空/清理顺序、初始化期间通知、子进程退出码、健康格式、损坏编码器、端点禁用/改名/冲突的回归验收。
## v0.12.0-preview(2026-09-24)
- 生成项目统一采用根目录 main.go 和 config/svc/logic/handler;new 与 api new 共用受管理生成器,manifest v2 不兼容旧目录。
- main 和 HTTP 装配持续生成;svc 提供 AppOptions、GlobalMiddleware、OnClose 和幂等 Close,初始化失败回收资源,正常退出先排空 HTTP 再关闭项目依赖及日志。
- 入口 @app(defaultResponse: Name) 自动接入 App 默认响应;生成路由、全局中间件及 404/405 共用策略,Go 错误目录保持单处注册。
- raw/multipart 输入和 @produces 流式输出支持自动 Handler,Logic 接收受限请求流/返回可关闭内容流,保留显式手写扩展。
- 新增真实生成项目生命周期、流式输入输出、文件保留和入口漂移检查,提供可运行 managedService 示例与 PROJECT.md。
- multipart 流式读取保留解析错误,检查未消费部分与最终边界;损坏请求默认 400,超限保持 413,错误目录可定制公开响应。配置校验与实际 App 响应编码器/错误目录装配使用同一规则,冲突在构造前报错。
- manifest 保存契约快照;生成/预览/检查提供接口类型、嵌套 DTO、scalar、body/流式模式变更提示,定位受影响的 Logic。轻量源码检查识别签名、语法和重复方法问题;--check 在重新生成后仍拒绝明确的业务签名错误,保留所有手写文件,类型别名/build tags 等不确定情况提示人工复查。
## v0.11.1-preview
- 修复生成路由用空错误目录遮蔽 App 目录的问题;空 WithErrors 完全透传,避免 Context 与 Encoder 二次解析时丢失状态、业务码和中文提示。
- 未声明错误列表的路由继承 App,支持错误只在 Go 中维护;显式路由目录保持独立,响应格式、RawJSON、body limit 和全局中间件提前返回保持正确策略。
- 兼容已有 v0.11.0 生成代码;CLI 不再为空列表绑定目录。补充纯 Go 错误目录的实际生成消费者与回归验收。
## v0.11.0-preview
- 错误目录增加有序 params 和命名提示模板,生成带类型的错误函数;固定提示保持兼容,业务不再自行拼接文案。
- 新增 WithParams、参数校验与共用解析器;复制请求参数、保留错误链,重复解析和缓冲链路保留填充结果。非法调用转系统兜底,访问日志提供不含参数值的诊断。
- OpenAPI 错误目录同步参数类型/示例,响应示例由运行时解析器生成;JSON/YAML 配置同样支持模板。
- 本地/共享幂等重放保存已解析公开错误快照,不保存原始参数或 cause;Redis Lua 重写记录不损失 int64 业务码精度。
- 模板编译拒绝未声明/未使用参数、非法类型及示例;模板错误不能再维护同标识的旧 messages 映射。
- CLI 新生成代码最低要求 v0.11.0-preview;已生成的固定错误函数和 v0.10.0 调用继续支持。
## v0.10.0-preview
- 新增 App/路由独立错误目录:`omni.WithErrors`、`errors.Catalog`、`ByCode` 与不可变 Registry,业务只选逻辑标识;对外码和提示由项目配置。
- 默认 HTTP 状态与逐项覆盖均可配置,包括 HTTP 200 的业务失败;错误模板、RawJSON、日志、业务失败指标、链路、标准中间件桥接和幂等重放保留失败语义。
- `.api errorCatalog` 同源生成 Catalog、独立错误函数、路由目录和 OpenAPI;允许重复对外码、不同路由不同码类型、公共契约导入。旧 error 声明也允许 HTTP 200。
- 同一 200 的成功/错误响应使用 anyOf 保留两种 schema,错误示例保持各自提示;自定义 Encoder 和直接输出继续支持。
- 目录校验类型、范围、状态和冲突;JSON 配置拒绝重复键,整数保留 int64 精度;未知标识使用系统兜底,默认公开响应不暴露 cause。
- 新生成代码最低要求 v0.10.0-preview;原有构造函数、Codes/Messages 与旧生成代码继续可用。Catalog 为生成错误包保留函数名。
## v0.9.1-preview
- 新增标准 `database.OpenConnector` 和独立 `PoolConfig`;与 DSN 入口共享池参数、限时启动 Ping、失败清理、Health、事务,不注册全局驱动配置。
- 新增 `omni.WithHTTPConfig` / `Config.HTTP`:读头、读请求、写响应、空闲超时和头大小,贯通代码、JSON/YAML、环境变量、Run/Serve 与 NewServer;nethttp 支持 Go-only ErrorLog。
- 保持已有默认行为;负值和实验 http2 不支持的超时项在监听前报错;记录各网络层的具体语义。
- 增加真实 MySQL TLS/驱动日志隔离、连接池归属/取消/事务、nethttp/native 慢连接与写背压验收,以及无本地 replace 的远程消费者脚本。
- 兼容 v0.9.0-preview 生成代码;不修改业务项目的数据库策略或生成代码最低版本。
## v0.9.0-preview
- 标准 HTTP Handler/Middleware 桥接:跟踪 Raw 状态与提交,保留取消、流式能力和中间件跨请求状态;已提交响应不追加 JSON。
- `.api` 支持动态 JSON `any` / `interface{}`,保持重复键、大小、外围未知字段校验和数字精度。
- 路由级请求体限额、自定义绑定和 manual Handler;上传、原始 body、下载可保留手写实现,重生成受保护。
- 自定义 scalar 显式对接项目 JSON/Text 方法;支持可空和集合,分别生成请求/响应 OpenAPI;不放宽其他字段。
- 新增 [迁移扩展指南](MIGRATION.md)、可运行示例和真实生成项目回归。CLI 与框架依赖一起升级。
## v0.8.0-preview
- 公共响应 `codeType` 支持 `int32` / `int64` / `string`,校验默认码及映射范围,OpenAPI 标注整数位宽;JSON/YAML 和生成代码保留 int64 精度,旧 `integer` 兼容。
- 公共 JSON 响应体改为可配置模板:支持嵌套结构、独立错误体、错误码类型/映射、提示与空值行为;旧 DTO 仅保留源代码兼容。
- `.api` 支持 `response Name (...)`、分组引用和单路由覆盖;`@response raw` 输出原始业务 JSON。生成的模板、路由和 OpenAPI 使用同一份配置。
- 提供应用级/接口级编码器,以及 Text / XML / Bytes 直接输出,允许三方回调独立控制成功和失败的状态、Header、内容。
- 接口响应策略可在全局中间件之前挂载,覆盖提前鉴权/限流失败和 Context 克隆;内置路由 404/405 使用应用配置。
- 详见 [公共响应体](RESPONSE.md)。CLI 与项目框架依赖需要一起升级到本版。
## v0.7.0-preview.1
- `.api` 支持集中业务错误目录、组级与路由级错误声明,生成状态/错误码/提示一致的 `internal/apierrors` 函数,以及 OpenAPI 命名响应示例。
- 具名鉴权声明支持 Bearer、header/cookie API key,关联真实中间件;支持组级继承、单路由覆盖与公共接口,认证 hook 优先执行并在启动时校验。
- 新增多行 `@doc` 的摘要、描述、标签与弃用标记,`info.description` 同步文档。
- 新增 `api inspect`、`api export`、`api generate --dry-run`;项目内自动识别 manifest 的 API 入口,减少重复参数。
- 用户示例用真实 401/403/409 验证声明与行为,业务直接使用生成的错误函数;生成不会改写现有手写 hook 和 Service。
- 纠正旧文档误将事务 Outbox、分布式锁列为已实现的描述;这两个模块当前没有实现。
## v0.7.0-preview
- 新增 `.api` 契约驱动生成器:DTO、严格多来源绑定与校验、路由、分组 Handler / Service / Repository、命名中间件及 OpenAPI;生成文件哈希管理、业务文件保护和只读 `--check`。
- `new --api` / `api new` / `api generate` 可以组合 MySQL、日志以及 Local / S3 / R2 / OSS。保存初始基础设施选项,重生成自动沿用;显式切换已有项目选项会拒绝,避免静默丢失初始化代码。
- 新增 `examples/userService` 独立业务示例与 `verifyAPIWorkflow.py`:用户 CRUD / 分页、管理员 Token 鉴权、MySQL 事务审计、故障回滚、文件导出、requestId 日志、数据库 readiness 和优雅退出;支持从远程版本验收。
- 日志新增文件轮转、结构化敏感字段脱敏、异常堆栈及慢请求配置;存储新增统一上传策略、直传签名和云端分片能力。
- 新增缓存、HTTP 客户端、限流 / 幂等 / 超时、后台任务、Redis 队列、审计、迁移与 telemetry 等可选模块;业务身份与 Orbit 保持由应用自行组合。
- `database.ApplyEnv` 支持文件/默认配置叠加环境变量;数据库配置使用 lowerCamelCase,DSN 不参与 JSON/YAML 序列化。
- 修复 native 在业务层提前拒绝请求体为 413 时等待继续读取 body 的问题,拒绝后关闭未读连接。
以下是自上一版本以来的网络层变化与历史验收记录:
- 新增 `TestNginxHTTP2Proxy` 与 `scripts/verifyHTTP2Proxy.py`:验证两段 TLS/h2、回源连接复用、证书校验拒绝、超时/客户端取消恢复,以及 GOAWAY 下的新旧实例切换和请求排空。Linux amd64、Nginx 1.30.5 普通与 race 模式各 3 轮通过;保留源码/镜像/日志校验信息和实际执行记录。
- HTTP/2 代理验收的编译与测试阶段顺序运行,均限制 2 核、2 GiB、256 个进程/线程;集成测试禁用外部网络并使用只读根文件系统。总时限、SIGTERM、SIGHUP 清理实测通过;native 默认协议保持 HTTP/1.1。
- 新增 `TestTLSMixedSoak` 与 `verifyHTTP2.py --scenario mixed`:8 条 TLS 连接、128 个 worker 持续混跑普通请求、慢上传和慢下载,校验 TLS/HTTP2、requestId、响应内容及传输摘要;分别统计三类请求与每条连接覆盖情况,并记录退出/GC 后的回收数据。
- 完成 Linux amd64 30 分钟多连接慢客户端混合长测:53,191,687 次请求(含 106,971 次慢上传、5,588 次慢下载),0 失败与协议错误;采样 RSS 峰值约 116.6 MiB,连接/请求、goroutine 和 FD 回收,释放测试数据后存活堆约 450 KiB。保留完整日志、120 份采样及每条连接的分类计数。
- 新增独立 `transport/http2` 实验 Runner:支持 h2c/TLS h2、连接上限、全局请求并发闸门、GOAWAY 排空、RST_STREAM 单流取消和生命周期统计;不改变 native HTTP/1.1 默认路径。
- HTTP/2 实验 Runner 增加标准 1 MiB 头部上限、默认 8 MiB 请求体上限、128 路同连接多路复用、2 MiB 慢读取流控验收、协议错误、RST_STREAM 和 GOAWAY 帧计数;已声明超限请求在进入业务 handler 前返回 413。
- 新增可配置时长的 HTTP/2 TLS soak:默认 3 秒,Linux 验收可通过 `GO_OMNI_HTTP2_SOAK_DURATION=30m` 覆盖,混合验证 128 路 stream、慢 handler、请求体、Keep-Alive 和退出回收。
- HTTP/2 soak 每 15 秒输出请求/失败数、Go 堆、Linux RSS、goroutine、FD 和协议统计;校验响应协议、内容和独立请求 ID,不再忽略压测期限附近已发出请求的失败。
- 补充 HTTP/2 慢响应流窗口为零时的隔离与恢复验收:同连接 204、另一连接带 body 响应、WINDOW_UPDATE 恢复发送及 RST_STREAM 释放请求槽位;补充 TLS 握手未完成时的连接上限拒绝、握手超时回收和正常请求恢复。
- 补充连接级接收窗口耗尽测试:保持 stream 信用充足,消费完 65,535 字节连接窗口后确认大响应暂停,同连接 204 和另一条连接仍可完成,连接级 WINDOW_UPDATE 恢复后继续复用;补充零窗口阻塞写在关闭超时后解除阻塞、取消请求与连接回收的验收。
- 新增 `scripts/verifyHTTP2.py`,固定在 Go 1.25 Linux 容器中运行 HTTP/2 TLS 长测;默认使用普通模式,`--race` 默认运行 60 秒。容器默认限制为 2 核、2 GiB 内存、256 个进程/线程,带总时限、中断清理和日志大小限制。
- 完成 Linux amd64 单 TLS 连接、128 个 worker 的 30 分钟 HTTP/2 长测:58,102,476 次请求,0 失败与协议错误,采样 RSS 峰值约 22.6 MiB(包含客户端和服务端),退出后连接/请求/FD 回收;保留原始日志和 120 份采样。
- native TLS 明确限制 ALPN 为 `http/1.1`:缺省自动宣告该协议,误配置 `h2` 等未实现协议时在启动阶段报错。
- native `Server.Stats()` 新增连接和请求过载拒绝计数;新增可信代理配置,访问日志只在 socket 对端属于配置网段时解析 `X-Forwarded-For`。
- 新增 transport-neutral `metrics` 包,支持请求数、错误数、in-flight、累计耗时和状态分布,并可直接注册 `/metrics` 接口。
- native 新增 `Server.Stats()`,提供 accepted connections、active connections 和 active requests 生命周期指标。
- native 请求体改为流式读取,Content-Length 与 chunked body 不再在进入 Handler 前整体复制到内存;保留大小限制、读超时、trailer 校验和 Keep-Alive 排空。
- 统一标准库与 native 的真实 TCP benchmark,增加固定并发档位、响应状态与读写错误检查。
- 新增独立进程并发与长连接内存测量工具,记录成功吞吐、P95/P99、服务端分配、空闲连接堆内存和回收情况;修正并行 `ns/op` 与客户端/服务端混合分配的解释。
- 加固 native HTTP/1.1 解析边界:校验 Host、重复 Content-Length、Connection token、Transfer-Encoding、控制字符和响应头名称;补充 HTTP/1.0 Keep-Alive、HEAD、204 以及异常请求测试。
- native 支持带 trailers 的 chunked 请求体,并继续通过 `MaxBodyBytes`、读超时和头部上限控制解析内存与连接占用。
- native 响应实现 `http.Flusher`:HTTP/1.1 流式响应自动使用 chunked framing,普通响应继续自动生成 `Content-Length`。
- native 在长 Handler 运行期间监测客户端断开并取消请求 context,流式写失败同步取消;短请求延迟启动监测,管线数据保留给下一次请求。补充断开、监测退出和长连接复用验收。
- native 增加 `ServeTLS` / `RunTLS` 和 `TLSConfig` 启动入口,App.RunWith 可以直接启用 TLS;补充真实 TLS 客户端握手、请求和优雅退出验收。
- native 请求补齐 `Request.TLS` 连接状态;Linux/arm64 容器通过全量 race、vet、build,以及 TLS 并发、Keep-Alive、慢握手和连接上限回收验收。
- 补充慢请求体超时、大请求体和同连接管线请求验收,确认流式 body 读取超时会取消请求并关闭连接,连续请求不会串线。
- 补充 `MaxConnections` 满载拒绝与槽位释放验收,确认请求完成后连接容量可再次使用。
- 补充 `MaxConcurrentRequests` 过载恢复验收,确认 503 拒绝后请求槽位释放并可继续处理新请求。
- 增加高频建连/断连的状态池 churn 验收,覆盖连接对象回收与容量复用。
- 新增 Linux Nginx 反向代理验收脚本,覆盖 native TLS、Keep-Alive、上游超时和优雅关闭。
## v0.6.0-preview
- native 网络层完成流式 Content-Length / chunked body、慢请求头、慢 body、慢 TLS 握手和 TLS 请求元数据验收;Handler 可在 body 未完全到达时开始读取,超时、畸形和超限 body 会被拒绝并关闭连接。
- 将 native 连接 churn race 验收扩展为 16 个 worker、3,200 次建连/断连,并覆盖明文与 TLS 的连接上限、Keep-Alive 和槽位回收。
- perfcheck 支持最长 30 分钟持续场景,新增 Linux 文件描述符采样;固定记录服务端堆、goroutine、连接数、accept 数和 FD 回收状态。
- 新增 `scripts/verifyLinux.py`,在 `golang:1.25` Linux 容器内统一执行 race、vet、build 和可选 TLS / 非 TLS 压测。
- 完成 Linux/arm64 30 秒高并发回归:128 并发流量、512 条空闲连接,TLS 与非 TLS 均为 0 错误,连接、goroutine 和 FD 回收正常;原始报告见 `performance/linuxSoak.json` 和 `performance/linuxSoakTLS.json`。
## v0.5.0
- 新增 `transport/native` 第一版自研 HTTP/1.1 服务端,接管 TCP 监听、连接、Keep-Alive、请求解析、响应写回和优雅关闭。
- 新增 `App.RunWith` / `omni.Runner` 启动边界;native 通过标准 `http.Handler` 接入,现有 Handler、Service 和 Repository 无需修改。
- native 支持 `Content-Length` 请求体、请求头 / 请求体限制、读写超时和服务关闭时的请求取消;通过对象池和 `MaxConnections` / `MaxConcurrentRequests` 控制高并发内存;chunked 请求体暂返回 501。
- native 支持 `Content-Length` / chunked 请求体、请求头 / 请求体限制、读写超时和服务关闭时的请求取消;通过对象池和 `MaxConnections` / `MaxConcurrentRequests` 控制高并发内存。
- `omni.New()` 默认仍使用只依赖标准库的 `transport/nethttp` 路由,native 作为显式启用的网络层预览实现。
## v0.4.0

199
CONFIG.md Normal file
View File

@@ -0,0 +1,199 @@
# 配置:文件明确声明值的来源
v0.22 起只使用这一条流程:
```text
项目 Default() → 解析选中的一个文件 → 解析字段值中的环境引用
→ 按 Go 类型绑定已提供的字段 → 项目 Validate() → 初始化日志和依赖
```
没有环境前缀、别名表或隐式二次覆盖。业务只维护 `Config`、`Default()`、`Validate()` 和配置文件。项目 Validate 中继续调用嵌套框架配置的 Validate;生成模板已包含这些调用。验证函数只检查值,不应打开资源。
## 直接使用
```go
type Config struct {
Server omniConfig.Config `json:"server"`
BatchSize int `json:"batchSize"`
Enabled bool `json:"enabled"`
Password string `json:"password"`
Timeout time.Duration `json:"timeout"`
}
func Default() Config {
return Config{Server: omniConfig.Default(), BatchSize: 100, Enabled: true,
Timeout: 3*time.Second}
}
func (c Config) Validate() error {
if err := c.Server.Validate(); err != nil {
return omniConfig.AtField("server", err)
}
if c.BatchSize < 0 { return omniConfig.Invalid("batchSize", "must not be negative") }
return nil
}
// 手写项目;生成项目的 Load 已自动调用此入口。
cfg, err := omniConfig.LoadProject("etc/config.yaml", Default())
```
```yaml
server:
address: "${HTTP_ADDR:-127.0.0.1:8080}"
password: "${DB_PASSWORD}"
timeout: "${REQUEST_TIMEOUT:-3s}"
enabled: false
```
文件未写 batchSize,保留 100;enabled 明确写 false,就使用 false。变量名字完全由文件决定,不需要对应 Go 字段名。配置文件可只列出部署需要变动的字段;不会自动导出/打印包含凭据的完整配置。
## 引用语法
| 文件里的值 | 含义 |
| --- | --- |
| `host: 127.0.0.1` | 固定值,不读取任何环境变量 |
| `password: "${DB_PASSWORD}"` | 未设置或空字符串报错,不回退到 Default() |
| `port: "${DB_PORT:-3306}"` | 未设置或为空时使用 3306 |
| `sessionToken: "${SESSION_TOKEN:-}"` | 未设置或为空时使用空字符串,是否合法由目标类型和 Validate 决定 |
| `url: "https://${HOST}:${PORT:-443}/v1"` | 普通字符串中可出现多个引用 |
| `literal: "$${DB_PASSWORD}"` | 得到字面量 `${DB_PASSWORD}`,不读取变量 |
| `literal: "$$cost"` | `$$` 转义一个 `$`,得到 `$cost` |
变量名遵循 `[A-Za-z_][A-Za-z0-9_]*`。只支持 `${NAME}`、`${NAME:-fallback}` 和 `$$` 转义,不支持 `${NAME-default}`、嵌套引用或 shell 表达式。普通单独的 `$` 保留。fallback 到第一个 `}` 结束,可包含冒号、`:-`、引号等文件格式允许的字符;需要包含 `}` 的值可使用固定值或环境变量。
解析只扫描文件的**已解码字段值**,不替换 YAML/JSON/XML 源文本,不扩展字段名,也不扫描 Default() 的字符串。插入的环境值和 fallback 不再展开。密码 `DDSFLKJlsf:saf:-123`、引号、换行、`${OTHER}` 均原样保留,不破坏文档结构。字符串不统一 TrimSpace;`"0"`、`"false"` 和全空格字符串均属于已提供。
同一变量每次加载最多读取一次;下一次加载会重新读取。没有全局变量值缓存。并发加载各自维护结果,不能把调用者正在修改的默认配置当作共享可变输入。
## 类型与覆盖
目标 Go 类型决定引用结果的转换:整数、浮点数、布尔值、`time.Duration`、字符串、指针及已有自定义 JSON/Text 反序列化。数值范围按目标位宽检查,整数不经过 float64。自定义反序列化仍由自定义类型解释文本,不猜测其业务含义。
- 文件缺失字段保留初始值;显式 0、false、空字符串正常覆盖,不用零值判断字段是否存在。
- `${MISSING}` 失败就报错;需要备用值必须写 `${MISSING:-value}`。
- 字符串编号 `"001"` 保持 `001`;绑定到 int 才转整数。环境里的 `null` 不自动变成空指针,`[1,2]` 不自动变成数组。
- 集合直接在文件里声明,可在每个标量元素中使用引用,例如 `ports: ["${PORT_A:-8080}", "${PORT_B:-8081}"]`。
- 数组整体替换;空 slice 保持非 nil;固定长度 Go 数组要求长度一致。map 合并键,已提供键的值整体替换。
- 固定文件 null 沿用 JSON 语义:清空指针/map/slice/interface,普通值字段保持初始值。需要区分 null 时使用指针。
- `any` 的引用结果保持字符串,不猜测动态类型。固定 JSON/YAML 数字使用 json.Number 保留精度。
- 引用成功后的值不会再覆盖一次。旧 `GO_OMNI_*` 名称只有出现在文件引用中才有作用;不隐式重置日志 file/rotation。
普通固定字符串仍按原有严格绑定规则处理;不会为了支持变量,把所有 `"123"` 都自动转换成数字。
## YAML、JSON、XML 一致
使用 Go 的 `json` 字段名,无需维护三份 tag。以下三份文件等价:
```yaml
port: "${PORT:-3306}"
password: "${PASSWORD}"
```
```json
{"port":"${PORT:-3306}","password":"${PASSWORD}"}
```
```xml
<config><port>${PORT:-3306}</port><password>${PASSWORD}</password></config>
```
XML 使用 `<config>` 根,标量占位符写在普通叶元素或 `type="string"` 元素中,由 Go 字段决定类型;显式 `type="number"` / `type="bool"` 表示文件里已经有合法数字/布尔常量。XML 数组用 `type="array"` 加 `<item>`;空对象 `type="object"`、null 用 `type="null"`。不从重复元素猜测数组。
`.api` 不经过配置加载器,响应模板 `${code}` 不变。**如果把响应模板写在 YAML/JSON/XML 配置文件里,须转义:**
```yaml
server:
response:
body:
code: "$${code}"
msg: "$${message}"
data: "$${data}"
```
加载后恢复为 `${code}` 等响应占位符,仍由响应编码器处理。
## 生成项目与检查
| 文件 | 维护方 |
| --- | --- |
| `main.go`、`internal/config/loadGen.go` | 生成器维护,纳入 `generate --check` |
| `internal/config/config.go` | 项目维护 Config、Default、Validate |
| `etc/config.yaml` 等选定文件 | 项目维护,重新生成不覆盖 |
| `internal/config/loader.go` | 初次生成即可使用,仅添加自定义格式时才修改 |
```sh
go run . -config etc/config.test.yaml -check-config
go run . -config etc/config.local.yaml
go build .
go-omni api generate --check
```
`-check-config` 使用与启动完全相同的 Load,通过时退出 0、失败时非零;不打印配置,不创建日志文件、数据库、后台组件或 HTTP 监听。它只校验配置,不检测真实数据库连接或证书握手。
`-config` 只选一个文件,不自动叠加 local/test/default。指定文件不存在时报错;`-config ''` 只使用 Default() 后校验,不读取任何环境变量,也不寻找其他文件。示例:[examples/configuration](examples/configuration/README.md)。
## 安全诊断与限制
```text
config "etc/config.yaml":7:9 env "DB_PORT" field "database.port": expected a valid int
```
`*config.Error` 包含 File/Field/Env/Line/Column/Reason。引用错误同时保留文件与环境变量名;组合引用失败可列出多个名字。行列定位原文件字段,不把环境值伪装成源文件文本。无法取得位置时仍提供可用的文件/字段信息。
用 `config.Invalid(field, publicReason)` 或 `config.AtField(prefix, err)` 报业务校验错误。未知字段给出唯一且接近的已声明字段建议,不自动修改。错误默认不打印原值;自定义 Reason 不应拼入秘密值。原始 cause 仍支持 errors.Is/As,不要把 Unwrap 后的错误或整个配置写到日志。
文件最多 1 MiB;单个环境值/展开字段最多 1 MiB,展开字符串合计/变量缓存分别最多 4 MiB;解析/展开限制 64 层、100000 个节点与引用计数。拒绝无效 UTF-8、多文档、重复字段、未知字段、非普通文件;YAML 不接受 alias/merge/custom tag,XML 不接受 DTD/外部实体。不执行 shell,不自动加载 `.env`。
## 框架模块的字段诊断
v0.23 起 MySQL(含连接池/TLS)、通用数据库池、日志(含轮转/时区)、HTTP 客户端、退出阶段和请求策略返回字段级安全原因。沿用项目已有的 `config.AtField("database", err)` / `config.AtField("server", err)`,自动组合嵌套字段,不需业务重复校验基础参数。
例如文件中 `database.port: "${DB_PORT}"` 的引用值为 70000:
```text
config "etc/config.yaml":3:9 env "DB_PORT" field "database.port": must be between 1 and 65535
```
日志级别非法指向 `server.log.level`;池参数指向 `database.pool.maxIdleConns` / `pingTimeout`。框架只公开已定义的安全原因,第三方/项目普通 error 仍显示 validation failed 并保留 errors.Is/As 原因链;业务使用 Invalid/AtField 提供公开原因。不要解析错误字符串;使用 `*config.Error` 字段。未提供字段的位置可能不可用,字段与原因仍保留。
生成项目现有 Load 和 `-check-config` 自动获得这些诊断。日志可在 `server.log.timeZone` 配置 IANA 名称,见 [实例时区](logx/README.md#实例时区)。
## 可选解析器与测试来源
内置格式无需项目手写加载器。需要额外格式时在已有 NewLoader 注册 Decoder,后续仍自动经过同一引用/绑定/校验流程:
```go
func NewLoader() (*omniConfig.Loader, error) {
return omniConfig.NewLoader(
omniConfig.WithDecoder("custom", omniConfig.JSONDecoder{}),
)
}
```
Decoder 返回 Document(map/string/bool/json.Number/nil/[]any,XML 类标量可用 Text),可提供 Positions。实例注册无全局 init;插件必须可并发调用、不修改共享输入,并且是可信 Go 代码。
测试或嵌入宿主可以用 `WithLookupEnv(func(string)(string,bool))` 替换引用查找函数。它仍然只读取文件明确引用的名称,不启用任何自动覆盖。普通业务不需要这个选项。
## v0.22 迁移:旧模式已移除
这是不兼容调整,不保留两套加载逻辑:
- 删除 `EnvOptions`、`LoadFromEnv`、`ApplyEnv`、`OverlayEnv`、`ApplyDefaults`、`ServerEnvAliases` 和 `env`/`envDefault` 标签处理;检出遗留标签会提示迁移。
- 删除 `database.ConfigFromEnv` / `database.ApplyEnv`;数据库配置作为项目 Config 的字段绑定。DSN 字段可在文件里使用 `dsn: "${DB_DSN}"`,序列化 database.Config 不输出 DSN 原值。
- `LoadProject(path, defaults[, loader])` 不再接收 EnvOptions;`LoadFile(path)`、`NewReloader(path, logger)` 不再接收前缀。
- `omni.LoadConfig(path)` 的参数现在是文件路径;空路径只用框架默认值。
迁移顺序:
1. 将 envDefault 默认值搬入 Default(),移除 env/envDefault 标签。需要文件绑定的旧 `json:"-"` 密钥字段改成明确 json 名称;不打印配置对象。
2. 按实际部署需要,在选定文件写环境引用。固定值保持固定,不按原前缀猜测。原先依赖环境切换日志输出的项目,应在文件明确配置 file/output/rotation。
3. 配置文件中的其他 `${...}` 模板改成 `$${...}` 字面量;`.api` 无需修改。
4. 手写项目改为 LoadProject。生成项目保留 Config/Default/Validate/NewLoader,删除旧手写 Load 后重新生成;main/loadGen 由生成器更新,业务配置文件不会被静默改写。
5. 升级匹配的 CLI 与框架,执行 generate、generate --check、go test、go build 和 `-check-config`,再使用真实部署配置启动。
低层 `DecodeFile(path, defaults[, loader])` 也解析显式引用,但不调用 Validate;默认值和原始 Document 都不会因失败而被部分修改。日志 Reloader 仍只允许 log.level 变化,其他静态字段变更要求重启。
## 国际化配置
通用国际化的 `i18n.Config` 可作为项目配置的字段显式加载并调用 Validate。正文词典使用 `i18n.LoadFS` / `DecodeJSON`,不经过环境引用展开。构建 Bundle 后通过 `WithLanguagePolicy` / `WithErrorLocalizer` 装配借用的只读实例;没有隐式环境变量或全局当前语言。见 [国际化指南](i18n/README.md) 和 [完整示例](examples/i18n/README.md)。
## 跨域配置
生成项目的 `server.cors` 由本加载流程直接绑定、校验并交给 App,不需要修改生成入口。支持固定来源数组或元素内显式环境引用;非法来源定位具体数组元素。默认关闭,完整配置与行为见 [CORS 指南](cors/README.md)。

166
COPY.md Normal file
View File

@@ -0,0 +1,166 @@
# 可选拷贝工具 copyx
v0.19 用普通 Go 调用取代 JSON 结构映射生成。`copyx` 只依赖标准库,不需要注册到 App,不执行 JSON 往返,也不会自动暴露数据库模型。普通 Logic 直接使用 `.api` 生成的 DTO;只有存在独立业务类型或需要隔离可变数据时才用它。
完整可运行代码:[examples/copying](examples/copying)。在框架根目录运行:
```sh
go run ./examples/copying
go test -race ./copyx ./examples/copying
```
## 最简单的调用
```go
import "git.yuntuo.hk/k2safe/go-omni/copyx"
type Request struct { Name string; Enabled bool }
type Input struct { Name string; Enabled bool }
req := Request{Name: "sample", Enabled: false}
var input Input
if err := copyx.Copy(&input, req); err != nil { return err }
```
同名、同类型字段直接赋值。默认不会跳过 `false`、`0`、空字符串。目标字段没有来源时报错;多余源字段默认忽略,使用 `WithStrictFields()` 可要求源字段也全部使用或显式忽略。
默认是浅拷贝:同类型指针、map、slice 共享引用,符合普通 Go 赋值。JSON tag 不参与字段匹配。不会自动调用方法、SQL Scanner/Valuer,或把 `int32`、`int64`、string、命名标量互相强转。
新字段:普通接口只改 `.api` 和使用该字段的 Logic。独立 DTO/业务类型都需要此字段时,两边各声明一次;同名同类型不增加拷贝配置。检查在运行时发生,实际类型对必须有测试,不能把运行时工具当作旧生成器的编译期字段覆盖检查。
## 类型预检
v0.23 新增可选预检,无需填写映射文件或构造测试值:
```go
// 参数方向是源 → 目标;与 Copy 使用同一组选项。
if err := copyx.Check[Request, BusinessInput](options...); err != nil {
return err
}
// 复用已有 Copier:传结果类型,不添加 Copy 所需的额外输出指针。
err := copier.Check(reflect.TypeFor[Request](), reflect.TypeFor[BusinessInput]())
```
适合 CI 测试或初始化检查;这是程序执行的类型检查,不是 Go 编译器自动证明。递归检查切片/数组元素、map 键值、指针、嵌套字段、改名、忽略和严格覆盖;用已访问类型对处理递归结构。不执行 converter,也不调用构造器,不读取实例值。错误路径如 `$.Rows[].Value: copyx: incompatible types: string -> int`。
Check 复用 Copy 的规则,包括浅拷贝对同类型容器的直接赋值:这种情况下内部 converter 不会自动逐项执行。需要处理内部字段时选择深拷贝或容器 converter。
- 未知具体值的源 interface 需要运行时验证;无法静态判定时返回 `ErrDynamicType`。项目可给该接口类型对提供精确 converter 并承担内容验证。
- 已注册 converter 的签名被接受,但函数不在 Check 中执行;其返回错误、panic、金额规则不能靠预检证明。
- nil 到非可空目标、map 键转换碰撞、深拷贝结果是否残留源引用继续在 Copy 时检查。Check 成功不会关闭这些保护。
- 实际 Copy 也修正了空集合的检查缺口:确定的嵌套类型不匹配不再因为切片/map 为空或数组长度为 0 而通过。需要这些转换时提供显式 converter;目标仍只在整体成功后更新。
- 不引入全局配置、映射语言或业务类型;Copier.Check 可并发调用,不共享每次检查的遍历状态。
示例:[developerTools](examples/developerTools/README.md)。普通同名同类型字段不需要额外配置。
## 深拷贝与构造器
```go
snapshot, err := copyx.Clone(source) // 保持源的静态类型,始终深拷贝
if err != nil { return err }
copier, err := copyx.New(copyx.WithDeepCopy(), copyx.WithStrictFields())
if err != nil { return err }
var result Output
if err := copier.Copy(&result, source); err != nil { return err }
```
`Copy` 的目标必须是非 nil 指针;`Clone[T]` 返回 `(T, error)`。重复执行时在初始化阶段创建 Copier 并复用;配置之后不可变,每次调用独立持有遍历状态,可以并发使用。源、目标的并发读写仍由调用方同步,自定义函数也须支持并发。
深拷贝会分配指针、map、slice,递归处理结构、数组、接口中的具体值。nil 集合与非 nil 空集合保持区别;slice 结果容量为长度,不复制长度之外的隐藏元素。普通循环和重复指针可处理。循环图应保留根指针,使用 `Clone(sourcePointer)` 或 `Copy(&targetPointer, sourcePointer)`;将循环指针根压成值结构会明确报错。
它保证结果不引用输入中的可变对象,不保证保留所有底层内存布局关系:不同重叠 slice 视图会分别复制,内部指针指向某个切片元素的关系也不重建。字符串和零尺寸值可安全共享;`time.Time` 按 Go 不可变值使用(Location 不做独立复制)。不要通过 unsafe 或 uintptr 隐藏可变引用后要求工具识别。
## 字段差异与自定义转换
```go
type Input struct { Name string; UpdatedAt time.Time; InternalID string }
type View struct { DisplayName string; UpdatedAt string; LocalNote string }
copier, err := copyx.New(
copyx.WithDeepCopy(),
copyx.WithStrictFields(),
copyx.WithFieldNames[Input, View](map[string]string{"Name": "DisplayName"}),
copyx.WithIgnoreSource[Input, View]("InternalID"),
copyx.WithIgnoreTarget[Input, View]("LocalNote"),
copyx.WithConverter(func(v time.Time) (string, error) {
return v.UTC().Format(time.RFC3339), nil
}),
)
if err != nil { return err }
out := View{LocalNote: "preserve"}
if err := copier.Copy(&out, input); err != nil { return err }
```
字段规则的类型参数必须是结构类型,字段名使用导出的 Go 字段名。构造时检查不存在的字段、重名及冲突。不支持字符串路径表达式;复杂嵌套差异为该嵌套类型注册 converter,或直接写 Go。
`WithIgnoreSource` 只是声明该源字段不参与使用;目标同名字段若仍需保留,还需 `WithIgnoreTarget`。忽略目标会保留原目标值,Clone 中则为零值。深拷贝时如果保留值仍与源共享可变引用,也会拒绝。
转换器的签名是 `func(S) (T, error)`,按**精确类型对**匹配,不能重复注册。金额、编号、日期格式由项目明确转换。需要逐字段不同规则时,给整个源/目标结构注册函数;函数返回独立的新目标。无需另一份映射配置或全局注册。
精确 converter 优先于 nil/零值策略,能收到 typed nil;例如 `func(*Money) (Money, error)` 可自行决定 nil 是否允许。绑定到 `any` 字段的 converter 能接收 nil interface,但顶层未带类型的 `nil` 没有精确类型可匹配。浅拷贝中已经直接共享的同类型容器不会继续逐项转换其内部;需要遍历内部时选择深拷贝,或为该容器类型注册 converter。
## 深拷贝不能原样返回可变引用
```go
copier, err := copyx.New(
copyx.WithDeepCopy(),
copyx.WithConverter(func(v []string) ([]string, error) {
return v, nil // 会返回 ErrSharedReference
}),
)
```
检查覆盖完整源图与转换结果,包括新父对象引用旧子对象、原 map、重叠子切片、`source[:0]` 的可写容量、指向源数组元素的指针,以及未导出字段中的引用。会检查每个 converter 的结果和最终目标,不允许静默降级为浅拷贝。
返回相同字符串、整数并不会报错,它们不是可变引用。正确切片 converter 可以写:
```go
copyx.WithConverter(func(v []string) ([]string, error) {
if v == nil { return nil, nil }
out := make([]string, len(v))
copy(out, v)
return out, nil
})
```
第三方 Decimal/自定义 Money 如果含未导出的指针,默认深拷贝拒绝;注册该类型的 converter,使用它自己的构造/Clone API 创建独立值,避免转成 float64 损失精度。框架不内置某一金额库的规则。无法独立复制的类型直接返回错误。
## nil、零值和错误
| 选项 | 行为 |
| --- | --- |
| 默认 `NilError` | nil → 可空目标保留 nil;nil → 非可空目标报 `ErrNil` |
| `WithNilPolicy(NilZero)` | 显式允许 nil → 零值;可空目标仍为 nil |
| `WithNilPolicy(NilSkip)` | nil 保留已有目标;新集合元素没有旧目标,保留元素零值 |
| `WithSkipZero()` | 显式跳过 Go 零值,包括 `false`、`0`、`""`;非 nil 空集合不是 Go 零值 |
所有 nil 策略都不把不兼容类型自动变成兼容类型。非 nil 指针与值之间可以按对应元素转换;nil 到非可空目标仍需选择策略。局部更新不能默认使用 NilZero/SkipZero,它们可能改变清空字段或布尔值的业务语义。
工具只能复制绑定后的 Go 值:如果输入类型已经将“缺失”和“JSON null”合并为 nil,无法恢复两者区别。需要三态时,应先设计包含 presence 信息的类型和绑定逻辑。
赋值使用临时结果,成功后才替换目标;转换失败不会交付部分结果。`*copyx.Error` 包含 Go 字段/索引路径,`errors.Is/As` 保留内部错误链,工具不主动在错误中加入源值。converter 自己返回的错误文本仍由项目负责。
converter 必须只读输入;其外部副作用、输入修改无法回滚,panic 原样传播。框架只检查与本次输入的共享引用,无法证明 converter 未返回外部全局缓存的引用。它是受信 Go 代码扩展,不是可运行不可信代码的沙箱。
## 支持范围与资源限制
- 支持结构体、指针、interface、slice、同长度 array、map。map key 转换后冲突或不可比较则报错。
- 导出匿名结构字段可按提升后的无歧义名称访问;源嵌入指针为 nil 且需要其字段时明确报错。跨类型目标包含私有字段时拒绝;同类型私有纯值可作为整体保留。
- 不克隆文件、数据库连接、网络连接、锁、原子状态。已知标准库资源/同步结构按值复制也拒绝;深拷贝中的非 nil func、chan、unsafe.Pointer 拒绝。其他封装资源类型应由项目排除,工具不能识别所有自定义句柄。
- 单次遍历深度上限 256、节点上限 200,000;内置分配的估算预算为 64 MiB,超限返回 `ErrLimit`。预算不是进程堆内存硬限制,reflect 元数据和 converter 自行分配不计入。大对象流式读写应使用 IO/存储接口。
- 只缓存不可变类型元数据,不缓存请求值、用户 converter 或实例策略。没有 `unsafe` 写入、JSON 往返、隐式数值截断或方法调用。
- 这是运行时反射工具,会比直接 Go 赋值多检查与分配。热点应复用 Copier,并用真实数据测量;性能记录见 [本版验收](reports/copyxAcceptance.md)。
## 从旧 mapping 迁移
v0.19 **彻底移除**映射配置加载、Go 类型加载、映射代码生成及 `pointerResult` 等附属选项。公共 `.api` 字段组合、scalar、中间件工厂、Repository 开关继续存在。
1. 先检查每个转换是否必要。普通 Logic 可直接使用生成 DTO;独立核心保留自己的类型。
2. 将 `mapping.Xxx(...)` 调用改为普通 Go 或小型 `copyx` helper。字段改名、nil、金额规则写在该 helper 同一处。指针返回可直接 `var out *Response; err := copier.Copy(&out, input)`。
3. 用真实类型对测试缺失/null/零值/空数组、字段覆盖、金额精度和错误链。需要源字段新增时提醒遗漏,启用 `WithStrictFields()` 并测试。
4. 从 `omni.gen.json` 删除 `mappings` / `mappingFiles`,删除无其他用途的映射 JSON 文件。
5. 确认已无调用,再删除 `internal/mapping/mappingsGen.go`。如之前手改过文件,先迁出手写内容。生成器遇到该旧文件会阻止生成并提示迁移,**不会静默删除**;其余业务文件照常保留。
6. 升级框架与 CLI 到同版,执行 generate、generate --check、go test ./...;多个服务再执行 workspace generate/check。新 manifest 自动去掉已移除文件的所有权记录。
历史发布记录保留旧能力说明,仅用于理解旧版本,不代表 v0.19 继续支持。

292
DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,292 @@
# 通用业务开发指南(v0.27.0-preview)
从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。
公共字段组合、自动中间件工厂和 Repository 开关,见 [生成能力指南](GENERATION.md)。必要的结构转换使用可选 [copyx](COPY.md),v0.19 已移除旧 JSON 映射生成。
## 返回数组、可空对象和 null
```text
@handler list
get /items returns ([]Item)
@handler find
get /item returns (*Item)
@handler save
post /item returns (null)
@handler remove
delete /item
```
| 契约 | Logic 返回 | 成功数据 |
| --- | --- | --- |
| `returns (Item)` | `(*types.Item, error)` | 对象;nil 被视为实现错误 |
| `returns ([]Item)` | `([]types.Item, error)` | 对象数组;nil 统一输出 `[]` |
| `returns ([]string)` | `([]string, error)` | 字符串数组 |
| `returns ([]int64)` / `([]bool)` | `([]int64, error)` / `([]bool, error)` | 数值 / 布尔数组 |
| `returns ([]any)` | `([]any, error)` | 动态 JSON 数组,元素允许对象、数组、标量和 null |
| `returns (*Item)` | `(*types.Item, error)` | 对象或 `null` |
| `returns (null)` | `error` | HTTP 200 和空业务数据 |
| 不写 returns | `error` | HTTP 204,无响应体 |
统一响应中的空数据是否显示,仍遵循项目模板的 `dataMode`;需要 `data:null` 就设置 `dataMode: always`。`@response raw` 输出业务数据本身,包括 `[]`、对象或 `null`。可用 `@status 201/202`;204 不能声明响应类型。嵌套字段继续支持既有 slice/map/pointer/scalar/any。
基础类型数组还支持 int/int8/int16/int32、uint/uint16/uint32/uint64、float32/float64。nil 根数组都输出 `[]`;`[]any` 在 OpenAPI 中是 `type: array, items: {}`,不限制元素形状,但元素必须可被标准 JSON 编码器序列化。`[]uint8`(Go 的 byte slice)会编码为 base64,故明确拒绝;数字数组可用 `[]int32`,二进制用下载接口。当前不支持根嵌套数组或根自定义 scalar 数组。
## 契约驱动的请求策略
入口文件可以声明默认策略,单个接口按需覆盖:
```text
@app (
defaultResponse: CommonResponse
request: {"unknownQuery":"reject", "utf8":"reject", "charset":"utf8"}
)
@handler create
@request (body: required)
post /items (CreateReq) returns (Item)
@handler touch
@request (body: emptyObject)
post /touch returns (null)
```
- `unknownQuery: reject|ignore`:是否拒绝未声明的 URL 查询字段;重复标量仍拒绝,数组用重复的同名 query。
- `utf8: reject|replace`:拒绝原始非法 UTF-8,或采用 JSON 的替换字符行为。不是只检查解码后的字符串。
- `charset: utf8|any`:`utf8` 仅接受未声明 charset 或 `utf-8`;不会对其他编码做转码。
- `body: auto|required|optional|none|emptyObject`:auto 按 JSON 字段推断;required 要求一个对象;optional 允许完全没有 body,但提供 body 后仍完整校验;none 禁止任何 body 字节;emptyObject 必须是 `{}`。
未设置的项继承 App 的 `config.Config.Request`,最后采用既有默认行为。`.api` 的 `@app` 显式字段优先于项目配置;路由 `@request` 优先于 App。raw/multipart 使用各自解析流程,不能套 JSON body 策略。普通 JSON 只解析一次;重复/未知 JSON 字段、约束、scalar 和 `any` 仍使用同一编译器。自定义 binder/manual Handler 的输入逻辑由项目显式负责。
没有 body 与提供 `{}` 不等价。optional 无 body 时 JSON 字段保留零值;需要业务区分是否出现某字段时使用 optional 指针字段。raw/multipart 不继承 App 的 JSON body 策略;GET/HEAD 如遇 App 的 required/optional/emptyObject 默认值,须明确覆盖为 auto 或 none。参数校验不会替代企业权限、记账规则或数据库约束。
## 声明式 multipart
```text
type UploadReq {
Title string `multipart:"title" binding:"required,max=80"`
Metadata AttachmentMeta `multipartJSON:"metadata,maxBytes=16384"`
Attachment File `file:"attachment,maxBytes=10485760"`
Extras []File `file:"extras,optional,maxFiles=3,maxBytes=1048576"`
}
type AttachmentMeta {
Category string `json:"category"`
}
@handler upload
@body (mode: multipart; maxBytes: 16777216; maxParts: 8; maxFieldBytes: 65536)
post /attachments (UploadReq) returns (AttachmentResp)
```
Logic 直接得到 `*types.UploadReq`,无需维护 Handler。`form` 仍表示 URL query;`multipart` 表示文本表单字段,可用基础类型、文本 scalar 和重复 scalar 数组;`multipartJSON` 是一个严格解码的 JSON 表单字段,可包含对象、数组和已声明的 scalar。
`File` 提供 `Name`、`ContentType`、`Size`、`Present()` 和 `Open() (io.ReadCloser, error)`。文件名和媒体类型属于客户端元数据,项目继续决定内容检测、存储权限和目标对象名。不要把客户端文件名直接用作存储路径。
```go
reader, err := req.Attachment.Open()
if err != nil { return nil, err }
defer reader.Close()
// 将 reader 传给项目选定的流式存储接口;不要先 io.ReadAll 整个大文件。
```
所有声明字段、重复/未知字段、文件数量和各级限额都会在进入 Logic 前校验。缺少必填项、重复标量/JSON 字段、文件与文本类型混用、畸形 JSON 均失败。JSON 的类型、未知字段和重复 key 检查与普通 JSON 一致。
默认总量 1 MiB、最多 128 个 part、单文本/JSON 字段 64 KiB;单文件默认受总量约束,文件数组默认最多 16 个。`@body` 可设置 `maxBytes/maxParts/maxFieldBytes/maxFileBytes`;字段上的 `maxBytes/maxFiles` 再收紧对应限额。总量包括 MIME 头和结尾数据;传输编码的 part 被拒绝,避免字节含义不明确。
文件使用有界复制写入权限为 0600 的临时文件,文本/JSON 字段在各自限额内缓冲。生成 Handler 返回或 panic 时关闭打开的文件 reader 并删除临时文件;不能把请求的 File 留到异步任务使用。失败不会交付半个 DTO,也不会进入 Logic。临时磁盘容量、并发请求上限与 HTTP 超时由部署配置共同约束。
不声明 multipart 字段时,保留原来的 `body *httpx.Body` 流式入口,不创建临时文件。该方式适合特殊协议;项目应在不可撤销副作用之前完成校验并调用 `body.Complete()`。下载和原始回调继续使用 `@produces` / `@body(mode: raw)`。
## 文件声明配置来源
项目维护 Config、Default()、Validate() 和配置文件;生成器维护 Load/main。流程为默认值→选定文件→显式 `${NAME}` / `${NAME:-fallback}` 引用→类型绑定→校验。没有前缀、别名或 env/envDefault 隐式覆盖。
固定值保持固定,缺失字段保留 Default(),0/false/空字符串正常覆盖;引用失败不回退初始化默认值。`$$` 转义 `$`,字符串凭据保留空格与特殊字符。
`go run . -config etc/config.test.yaml -check-config` 只校验,不打开日志、数据库或端口。生成/check/dry-run 保留业务文件;新的模板要求 v0.22。完整格式、错误定位、解析器与移除清单见 [CONFIG.md](CONFIG.md)。
## 依赖、日志与中间件
`internal/svc/runtimeGen.go` 持续由生成器维护。项目的 ServiceContext 嵌入 `*lifecycle.Runtime`,只在 `Init(ctx)` 中装配额外依赖:
```go
func (c *ServiceContext) Init(ctx context.Context) error {
client, err := NewClient(c.Config.Client)
if err != nil { return err }
c.Client = client
c.OnClose(client.Close)
return nil
}
```
每个资源初始化成功就登记清理;后续初始化失败自动逆序回滚。正常退出先排空 HTTP 请求,再执行后台组件的 Stop/Drain 阶段,确认收尾后逆序关闭依赖,最后关闭日志。关闭幂等,单个清理函数 panic/返回错误不会跳过其他清理;初始化 panic 清理后继续传播。后台组件按 [分阶段关闭](lifecycle/README.md#后台组件分阶段关闭) 登记;无法确认收尾完成时保留共享依赖并异常退出,不能把取消等同于任务已经结束。
新项目使用中间件工厂:项目维护 `middleware.Dependencies` 小接口与 `New<Name>(deps) (httpx.Middleware, error)`,svc 提供对应访问器。生成的 runtime 在 Init 成功后自动调用工厂并注册,不再按名字替换 map;工厂失败会释放已经登记的依赖。示例与现有项目迁移步骤见 [中间件工厂](GENERATION.md#中间件工厂自动注册)。
v0.16 项目及 `omni.gen.json` 中选择 `middleware: handlers` 的项目继续使用原有 `Defaults()`,可以在 Init 设置 `c.Middlewares["RequireAccess"]`。更新时不会重写这部分业务代码;只有显式切换到 factories 后才使用新流程。
生成入口为每个 App 注入独立 Logger,不修改进程默认 Logger。业务可用 `c.Logger.InfoContext(...)`,或请求链中的 `logx.InfoContext(ctx,...)`。标准库/第三方需要 `*log.Logger` 时使用 `c.Logger.Standard(slog.LevelWarn)`;适配器借用原 Logger,不负责关闭。手写多 App 用 `omni.WithLogInstance(logger)` 显式注入。
`httpx.Metadata(ctx)` 在业务的标准 context 中提供可信代理规则解析后的 ClientIP、HTTP Method 和路由模板;不放入凭据、完整 URL、查询或 body。业务 userId/tenantId 等仍由项目选择,框架不预设身份模型。
`middleware.ContextErrors("REQUEST_CANCELED", "REQUEST_TIMEOUT")` 可将原始取消/超时错误映射到项目错误目录;显式 APIError 优先,内部 cause 链保留。状态、业务码与提示仍在项目 Catalog 配置。
## 字段文档、格式化、Go 错误目录导出
```text
type Item {
// 企业名称
Name string `json:"name" example:"示例企业"`
State int `json:"state,options=0|1" example:"1" enumDescriptions:"[\"禁用\",\"启用\"]"` // 状态
}
```
字段前/同行注释同步到 Go 注释和 OpenAPI description。显式 `description` 标签优先;example 的基础字段值也检查类型和约束,枚举说明按声明值顺序填写。字符串 example 直接写内容,其他类型写 JSON。自定义 scalar 的示例不能替代其项目解码器验证;输入输出类型不同时,不把输入示例冒充输出示例。
```sh
go-omni api format --api api/service.api
go-omni api format --api api/service.api --check
go-omni api generate
go-omni api generate --check
```
格式化保留注释、token 和多行字面量,规范四空格缩进、空行、行首尾空白及相邻单行字段的类型/标签/行尾注释对齐;结果幂等。先校验完整契约,check 不写文件;导入的共享目录不被某个服务的格式化命令修改。DTO 按源 `.api` 输出 `dto<Source><摘要>Gen.go`,都属于 `types` 包,跨文件引用无需改包名。
Go Catalog 是错误的唯一来源时,优先使用 workspace 的显式导出配置,不再维护导出/复制脚本。AppOptions 仍通过 `omni.WithErrors(apierrors.Catalog())` 注册同一个 Go 函数,运行期只注册一次。单个项目也可继续使用 `errors.ExportCatalog` 导出入口目录的 `api/omni.errors.json`;此文件和下面的共享 source 二选一。
OpenAPI 自动把有收益的重复响应、schema 和示例移到 `components.responses/schemas/examples`,通过标准 `$ref` 引用。HTTP 200 同时含成功和业务失败时保留两种 schema、业务码、模板参数示例和 data:null;业务响应内容不会改变。消费 OpenAPI 的工具需要支持标准本地引用。
## 两个服务共享契约
```text
workspace/
├── contracts/common/response.api
├── services/api/api/service.api
├── services/api/api/omni.imports.json
├── services/id/api/service.api
├── services/id/api/omni.imports.json
└── omni.workspace.json
```
两个服务的 `omni.imports.json` 可以分别声明:
```json
{"roots":{"common":{"path":"../../../contracts/common","version":"1.0.0"}}}
```
```text
import "@common/response.api"
```
只读取入口目录和显式声明的根。保留真实路径检查、symlink 越界拒绝、循环检测、32 层、64 文件、单文件 1 MiB/总量 8 MiB 限额;最多 16 个共享根。不会下载依赖或复制共享定义进服务。移动目录时显式更新 roots。
`version` 是项目管理的版本标签。生成文档记录每个文件 SHA-256,以及每个共享根所导入文件集合的摘要;可把文档中的 `x-omni-contract-sources.shared.common.sha256` 填入 root 的 `sha256`,固定输入集合,内容变化后必须显式更新。摘要算法是对“该根内相对路径→文件 SHA-256”的键排序 JSON 对象再做 SHA-256,未导入的文件不参与。
工作区清单:
```json
{"services":[{"dir":"services/api"},{"dir":"services/id"}]}
```
```sh
go-omni workspace format --file omni.workspace.json
go-omni workspace generate --file omni.workspace.json
go-omni workspace check --file omni.workspace.json
```
各服务保持独立 module;可显式填写各服务的 `api`(相对服务目录)和 `module`。所有服务预检通过后再写;每个服务独立提交文件事务,不承诺跨项目的崩溃原子性。check 汇总格式与生成漂移;业务编译/测试仍在各 module 执行。
### 混合 API 与通用工程
v0.28.1-preview 起,workspace 支持在同一份清单中管理 API、通用工程,也支持仅包含通用工程的工作区:
```json
{
"services": [
{ "dir": "apps/interface", "kind": "api" },
{ "dir": "apps/program", "kind": "project" }
]
}
```
`kind` 可省略:根据 `.goOmni/apiManifest.json` 或 `.goOmni/projectManifest.json` 识别。没有清单时,沿用旧 API 解释,需显式提供 api 入口才能首次生成。两种清单同时存在、显式 kind 与已有清单冲突、未知 kind 均报错。project 类型不能填写 api;可选 module 用来核对工程身份,不会重写 go.mod。
| 命令 | API 工程 | 通用工程 |
| --- | --- | --- |
| workspace generate | API 生成器 | project 生成器 |
| workspace check | 契约格式检查 + API 生成漂移检查 | project 生成漂移检查 |
| workspace format | 格式化 API 契约 | 显示 SKIP,不修改 Go 文件 |
检查会继续处理独立工程并汇总错误,任一失败返回非零。生成/格式化先预检全部目标;预检失败时输出 FAIL 与其他工程的 SKIP,不写任何生成结果。清单语法、目录越界/重叠或共享目录提供方失败属于工作区级错误,会直接中止。已有文件保护与路径规则保持,事务仍以单个工程为单位。
workspace 不创建或修改 go.work、go.mod、go.sum,不替代 Go 编译和业务测试。它只调用生成器;显式 errorCatalogs 导出仍按下节约定执行 Go 提供方,配置类型不会额外启动服务。完整 [混合工程与 go.work 示例](examples/mixedWorkspace/README.md)。
### 同一份 Go 错误目录供多个服务生成
在 workspace 清单显式声明提供目录的 Go 包和函数:
```json
{
"errorCatalogs": [{
"dir": ".",
"package": "example.com/project/internal/apierrors",
"function": "Catalog",
"output": "contracts/common/errors.json"
}],
"services": [{"dir":"services/api"}, {"dir":"services/id"}]
}
```
`dir` 相对 workspace,必须包含 go.mod;函数签名为 `func Catalog() errors.Catalog`。允许 module 内的 internal 包。先准备 output 的父目录。生成器构建一个临时导出程序,使用 Go overlay、临时 go.mod/go.sum 和 `-mod=readonly`,不会往项目增加导出命令或修改 go.mod/go.sum。最多 16 个提供方,每个有 90 秒构建/执行总时限和 1 MiB stdout 限制。错误消息不回显程序输出。
只有显式运行 **workspace generate/check** 会执行清单指定的 Go 包及其依赖初始化;不要指向不信任的包。提供目录的函数应只构造目录,不连接数据库、启动服务、写 stdout 或修改项目。框架不扫描其他包,也不通过 shell 执行配置字符串。普通 api validate/export/generate 和 workspace format 只读取现有元数据。
各服务在自己的 `api/omni.imports.json` 引用共享 JSON:
```json
{
"roots": {"common":{"path":"../../../contracts/common","version":"1.0.0"}},
"errors": {
"source": "@common/errors.json",
"include": ["USERNAME_INVALID", "INTERNAL_ERROR"],
"routes": {"GET /permissions":["INTERNAL_ERROR"]}
}
}
```
`include` 省略或 null 表示全部目录,`[]` 表示不列业务错误;routes 按 **大写方法 + 空格 + .api 原路径**(如 `GET /users/:id`)覆盖服务级列表。错误标识、路由拼错或重复标识会明确报错。路由已经通过 `.api @errors` 声明独立运行期目录时,不能再给它设置文档 routes 列表。
**这些列表只决定文档展示,不修改 App 错误目录、路由继承或运行期可返回的错误。** `.api` 不需要重复定义 Go 错误表。状态、业务码、提示和模板参数仍来自同一 Catalog;特殊自定义 Encoder 的文档由项目显式扩展。
```sh
# 首次生成导出一次共享目录,所有服务预检通过才写入
# 共享 JSON 不复制到各服务;已有 api/omni.errors.json 请显式移除后改用 source。
go-omni workspace generate --file omni.workspace.json
go-omni workspace format --file omni.workspace.json
go-omni workspace generate --file omni.workspace.json
go-omni workspace check --file omni.workspace.json
```
共享 JSON 应纳入版本控制。check 在内存重新导出并比较 JSON,然后检查所有服务;缺失或过期时失败,不更新项目文件。Go 构建缓存仍可能更新;显式提供方的自有副作用不属于生成器可隔离范围。业务 CI 仍执行各 module 的编译与测试。目录函数本身有变化时重新 generate,受控共享根的固定 SHA256 如需改变仍由项目显式更新。
## v0.13 项目升级
先使用与目标框架匹配的 CLI;CLI 会提示与 go.mod 不同的版本。当前生成模板至少需要 v0.22.0-preview(或显式引用当前源码)。
```sh
go-omni api upgrade --dir . --dry-run
go-omni api upgrade --dir . --preview .goOmni/upgradePreview
# 只有标为 safe 的原始模板才会自动替换;review 阻止全部写入。
go-omni api upgrade --dir . --apply
go mod tidy
go-omni api generate --check
go test ./...
go build .
```
自动迁移只替换与 v0.13 原始模板一致的配置/svc 基础文件、移除原始 close.go,并更新最低依赖及生成产物;保留 Logic、Repository、AppOptions、独立命令。迁移失败回滚已改的项目文件。存在业务修改时,仅输出这些基础文件的候选版本和清单,不生成整棵业务代码让人重新拼接。
定制项目按预览定点迁移:保留依赖字段,把初始化移到 Init,嵌入 Runtime,清理改为 OnClose 登记;移除旧 New/Close/OnClose;合并业务配置和结构化 MySQL 配置,更新 go.mod,再正常 generate。生成器会拒绝遗留构造/关闭实现,避免重复函数或悄悄绕过清理。v0.12 及更早的定制结构不能被精确自动合并,使用同一预览逐项迁移并编译验证。

116
GENERATION.md Normal file
View File

@@ -0,0 +1,116 @@
# 公共字段、中间件工厂与生成选项
本页对应 `v0.19.0-preview`。CLI 与框架依赖固定到同一版本,安装见 [README](README.md#准备环境与安装)。
## 一份项目生成配置
新项目根目录的 `omni.gen.json` 只描述生成选项:
```json
{
"repository": "none",
"middleware": "factories"
}
```
- `repository: none` 默认不创建空 Repository 文件;`group` 按 group 补齐可编辑骨架。已有文件始终保留;切回 none 不会删除业务文件。SQL、ORM 和仓储划分由项目决定。
- `middleware: factories` 是新项目默认值,自动调用带依赖的工厂。`handlers` 保留原有 `Defaults()` 及手动注册方式,供既有项目迁移。
- 该文件与 `.api` 一起提交。generate/check/dry-run 和 workspace 采用相同规则;validate/export/format 只处理接口契约。
- 旧 manifest 没有配置时补 `middleware: handlers`、`repository: none`。已有配置不能直接删除,避免意外切换中间件模式。
- v0.19 已删除 `mappings` 和 `mappingFiles`;遇到旧配置会明确报错,不读取子映射文件或执行映射生成。处理步骤见 [copyx 迁移](COPY.md#从旧-mapping-迁移)。
## 公共字段组合
```text
type EmployeeFields {
// 员工姓名。
Name string `json:"name" binding:"max=80"`
Salary Money `json:"salary"`
}
type EnterpriseRequest {
EmployeeFields
}
type AdminRequest {
EmployeeFields
CompanyID string `path:"companyId"`
}
```
`Money` 由项目按既有 scalar 语法声明。组合行只有已声明的对象类型名,不加指针或字段 tag。公共对象可以通过已有相对 import 或声明过的共享根 `@common/...` 引入。
生成器展开为普通 Go 字段。JSON 保持平铺;字段的绑定来源、校验、注释、可空性和 OpenAPI 使用同一份展开结果。不会在 JSON 中增加 `EmployeeFields` 一层。重复 Go 字段、重复来源字段名、循环组合、未知对象和过深展开均失败;即使重复字段类型相同,也不能隐式覆盖。要改变某个字段的规则,应拆出更小的公共组合。
允许多层组合,上限 32 层、单类型 4096 字段、全契约展开 65536 字段。Go 匿名指针嵌入并非本功能;生成 DTO 中不会使用匿名嵌入。
## DTO 与业务类型
普通 Logic 直接接收生成的 Request、返回生成的 Response,不额外创建相同结构。只有独立核心或明确不同的输入/输出职责才保留业务类型。
必要转换直接写 Go,或用可选 `copyx.Copy(&target, source)`。同名同类型字段自动赋值,字段改名和类型变化通过 Go 参数、转换函数明确设置,见 [完整示例](examples/copying/main.go)。无需 JSON 映射配置、包路径字符串或额外生成文件。
`copyx` 在运行时检查类型和字段,**不提供旧映射生成阶段的静态字段覆盖保证**;CI 应给实际类型对添加测试。普通业务字段新增通常只改 `.api` 和 Logic;独立 DTO/领域类型都需要此字段时,两边各加一次,同名同类型无需追加拷贝配置。
## 中间件工厂自动注册
`.api` 沿用 `middleware: RequireAccess` 或 auth 对应的中间件声明。新项目会生成可编辑的 `internal/middleware/requireAccessFactory.go`,只需实现一次:
```go
// internal/middleware/dependencies.go,由项目维护。
type AccessChecker interface {
CheckAccess(context.Context, string) error
}
type Dependencies interface {
AccessChecker() AccessChecker
}
// internal/middleware/requireAccessFactory.go,由项目维护。
func NewRequireAccess(deps Dependencies) (httpx.Middleware, error) {
checker := deps.AccessChecker()
if checker == nil { return nil, errors.New("access checker is required") }
return func(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error {
if err := checker.CheckAccess(c.Context(), c.Header("Authorization")); err != nil {
return err
}
return next(c)
}
}, nil
}
// internal/svc/access.go,由项目维护;ServiceContext.Init 初始化 c.Auth。
func (c *ServiceContext) AccessChecker() middleware.AccessChecker { return c.Auth }
```
编译器检查 ServiceContext 是否实现依赖接口,middleware 不导入 svc。多个工厂可以共用这些小接口。所需 imports 和具体鉴权服务仍属于项目。
启动顺序为:框架基础依赖 → `svc.Init` → 生成的 `middleware.New(c)` 调用各具名工厂 → 创建 App/注册路由 → 监听。中间件实例创建一次,由请求并发复用;不要把单次请求信息存在实例字段里。工厂之间没有初始化顺序依赖;路由执行顺序仍以 `.api` 声明为准。
工厂返回错误或 nil 中间件均阻止监听;错误链保留,已登记资源按现有生命周期清理。工厂 panic 也先清理依赖再传播。资源初始化和清理登记放在 svc.Init,工厂借用资源;HTTP 请求排空之前不会关闭这些依赖。
factory 模式不再在 Init 写 `Middlewares["..."]`;残留非空手写注册表会明确报错,防止生成注册静默覆盖它。未实现的工厂让启动失败;未实现的 Logic 仍返回 501。
### 既有项目迁移
1. 首次更新保持 `middleware: handlers`,现有 hook 和手写 Configure 不变。
2. 改为 factories 并重新生成,会补 `dependencies.go` 和 `*Factory.go`,保留原来的手写文件。
3. 将旧实现迁入工厂,提供 Dependencies 的访问器;移除 Init 中按名字覆盖 map 的代码。
4. 旧函数不再需要时由项目删除。若新工厂符号与旧文件中的同名函数冲突,整理旧文件;生成器不覆盖业务代码。
5. `generate --check` 与 `go test ./...` 检查后再运行。工厂没有全部实现前,服务不会启动。
## 文件维护边界
| 项目维护 | 生成器持续维护 |
| --- | --- |
| `.api` 公共字段与接口 | 展开后的 DTO、Handler、OpenAPI |
| `omni.gen.json` 的 Repository/中间件选项 | 对应骨架和注册代码;已有手写骨架不覆盖 |
| 独立 Go 业务类型、scalar、可选 copyx 调用和测试 | 不生成结构转换文件 |
| middleware Dependencies、具名工厂、svc 访问器 | 中间件 registry、启动调用 |
| Logic、Repository、配置、svc 初始化/清理登记 | main、runtime、路由接入 |
独立三端工资契约测试位于 `cmd/go-omni/apigen/testdata/composition`,不导入任何接入项目。它验证公共字段、Money scalar、三端 Handler、copyx、重新生成保留业务内容,以及新增字段不需要更新转换规则。
```sh
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test ./cmd/go-omni/apigen -run TestCopyxThreeEndpointConsumer -count=1
```

182
MIGRATION.md Normal file
View File

@@ -0,0 +1,182 @@
# 已有 HTTP 项目逐步接入
从 `v0.9.0-preview` 开始,以下能力贯通运行时、`.api`、代码生成和 OpenAPI。CLI 和项目框架依赖一起升级;入口见 [README](README.md)。这些都是通用扩展点,框架不内置任何项目的 ID、支付回调、鉴权或数据库规则。
## 1. 保留标准 Handler 与中间件
```go
app := omni.New(omni.WithLogger(true))
app.Use(httpx.WrapMiddleware(existingMiddleware)) // func(http.Handler) http.Handler
// go-zero 风格的 func(http.HandlerFunc) http.HandlerFunc:
app.Use(httpx.WrapFuncMiddleware(existingFuncMiddleware))
app.POST("/legacy", httpx.WrapHandler(http.HandlerFunc(existingHandler)))
```
实际使用时先配置所有 `Use`,再注册路由。`WrapHandler` 也接受 `http.FileServer` 等标准 `http.Handler`。
- Handler 经过 App 的日志、requestId、恢复和应用中间件链,原来的 URL、状态码、Header、响应内容由它自己控制。
- `c.Raw()` 的写入也会更新 `c.Status()` / `c.Responded()`;404 会记为 404。提交响应后返回错误或 panic,不会再追加错误 JSON。103 等临时响应不算最终提交。
- 桥接器不缓存完整响应。下载继续流式复制;底层支持时保留 Flusher、Hijacker、Pusher、ReaderFrom、CloseNotifier。`http.ResponseController` 可沿 `Unwrap` 访问底层控制能力。能力仍取决于网络适配器和其他中间件。
- 请求 context、取消信号和已有值继续传递,Omni 的路径参数可用 Go 1.22 的 `r.PathValue("id")` 读取。其他框架私有的参数读取器仍需由项目适配。标准中间件应从原 `r.Context()` 派生上下文;不能换成无关的 Background,也不能在 ServeHTTP 返回后调用 next。异步 timeout middleware 不在此桥接器的契约内。
- 标准中间件在每条注册链构造一次,状态会跨请求保留。下游返回的错误在标准中间件退出前编码,因此压缩、审计等中间件能处理错误响应。它们自己的缓冲或改写行为仍由项目负责。
调用流使用 `App.Handle/GET/POST` 等入口即可,不要绕到原始 Router 注册。暂不使用 `.api` 的旧接口也能这样接入。
## 2. 动态 JSON:允许渐进补全 DTO
```go
type LegacyReply {
Data any `json:"data"`
}
```
`interface{}` 是 `any` 的等价写法,支持 `[]any`、`map[string]any` 和对应的 `interface{}` 写法。带方法的 interface 不支持,会提示明确的类型错误。
动态字段允许 object、array、string、number、bool、null;OpenAPI 字段 schema 为 `{}`,不假装知道内部结构。它放开的是**该字段的内容**,外围 DTO 未声明的字段仍被拒绝,JSON 重复键、深度、总大小限制仍生效。动态数字解析为 `json.Number`,避免大整数先转 float64 丢精度。原始 JSON 的空白、转义和字节布局不会保留;验签请走原始 body 入口。
这不会自动补齐缺少的业务路由,也不代表兼容全部 goctl 语法。能确定结构的新接口优先使用命名 DTO。
## 3. 按路由限制 body 与保留手写入口
普通 JSON 路由只声明需要的大小,不改变其他路由默认的 1 MiB:
```text
@body (mode: json
maxBytes: 2097152
)
@handler createItem
post /items (CreateItemReq) returns (ItemResp)
```
`maxBytes` 单位为字节,允许 1 至 1 TiB;不写时为 1 MiB。已知超长或读取未知长度/chunked body 超限都按 413 处理。内置 App/RouterGroup 在全局中间件读取前安装 reader 限额;同时保持接口响应策略。网络层若有更小限额,以更小值为准。手写 Handler 吞掉读取错误或已经提交响应后,框架不能重新改写 HTTP 状态,务必检查读取错误。
### 只替换绑定函数
```text
@binding custom
@body (mode: json
maxBytes: 65536
)
@handler createItem
post /items (CreateItemReq) returns (ItemResp)
```
首次生成 `internal/handler/<group>/createItemBind.go`:
```go
func (h *Handler) bindCreateItem(c *httpx.Context, req *types.CreateItemReq) error {
// 调用项目自己的解码器,填充 req;失败返回 error。
}
```
Handler 继续调用 Service 并使用统一响应;只把绑定过程交给这个函数。初始 hook 返回 501,不能忽略实现。自定义绑定需自己实现 Content-Type、未知字段、尾部数据等校验,并遵守 DTO 文档;框架负责路由总大小限制,不隐式套第二次 JSON 绑定。
### 上传、原文验签、下载
```text
@handlerMode manual
@body (mode: multipart
maxBytes: 10485760
)
@handler upload
post /files
@handlerMode manual
@body (mode: raw
maxBytes: 65536
)
@produces "text/plain"
@handler callback
post /callback
@handlerMode manual
@produces "application/octet-stream"
@handler download
get /download
```
首次生成 `<operation>Manual.go`,由项目实现整个 `func(h *Handler) Callback(c *httpx.Context) error`;依赖通过 `h.deps` 访问。生成器保留路由、中间件与文档管理,不会预读、JSON 解码或重新包装手写输出,也不生成这个路由的 Service 方法。需要调用 Service 时自行组合。
- manual 无返回类型默认成功状态 200;也可 `returns (UploadResp)` 描述 JSON DTO,手写实现按声明调用 `c.OK` 等。`@produces` 用于无 DTO 的手写输出,描述成功响应媒体类型,不会自动设置运行时 Header。
- raw 的文档为二进制请求体;multipart 为开放对象,具体文件字段与逐部分校验由项目实现。raw/multipart 请求 DTO 仅可含 path/form/header 字段,不能同时声明 JSON body 字段。`form` 始终指 URL query。
- 原文验签直接读取 `c.Request().Body`,在声明的小限额内 `io.ReadAll`;大文件用 `MultipartReader` 配合 `io.Copy` 流式处理,不能整个读入内存。
- 使用 `ParseMultipartForm` 时,其内存参数只是内存阈值,不是总上传限额。App/桥接器在请求结束(含错误和 panic)后删除临时文件;业务仍须关闭 `FormFile` 得到的文件。`MultipartReader` 不自动建临时文件,业务自行创建的文件由业务清理。
- 手写 Handler 的成功输出由它控制;返回的 error、鉴权拒绝仍走接口的错误策略。三方若要求错误也返回指定文本,使用 [自定义 Encoder](RESPONSE.md#手写路由配置文件与三方回调) 并维护对应错误文档;`@produces` 不会改变错误协议。
直接注册 Go 路由:
```go
app.HandleWithBodyLimit("POST", "/upload", 10<<20, nil, uploadHandler)
// nil encoder 沿用 App 策略,也可传项目自己的 response.Encoder。
```
通用注册器使用 `httpx.HandleWithBodyLimit(...)`;实现 `httpx.BodyLimitRegistrar` 可像 App 一样在全局中间件前限流量,未实现时只能在路由中间件入口限制。单独 `httpx.LimitBody` 同样受中间件顺序影响。
### 重新生成规则
`handlersGen.go`、`routesGen.go`、DTO、OpenAPI 继续由生成器管理;`*Bind.go`、`*Manual.go` 只首次创建,后续不会覆盖或删除。切换 manual 回自动模式时,先迁移实现并自行移除对应 Manual 文件,否则生成器明确拒绝,避免重复方法。修改 DTO 导致手写函数签名不匹配时,需要一起调整项目代码;`go test ./...` 是必要检查。
## 4. 自定义标量:项目定义行为,框架连接调用
例如输入 ID 兼容数字/字符串、输出统一为字符串:
```text
scalar BusinessID (
goImport: "example.com/billing/internal/ids"
goType: ID
jsonInput: string | int64
jsonOutput: string
text: true
)
type ItemReq {
ID BusinessID `path:"id"`
}
type ItemResp {
ID BusinessID `json:"id"`
}
```
`goImport`/`goType` 指向项目已有的具体 Go 类型,不是框架内置 ID。生成器创建类型别名和 `types.ContractScalars()`,然后把注册表显式传给每个 Binder。运行时使用标准接口:
| 项目类型的方法 | 用途 |
| --- | --- |
| 值接收者 `MarshalJSON() ([]byte, error)` | JSON 输出 |
| 指针接收者 `UnmarshalJSON([]byte) error` | JSON 输入 |
| 指针接收者 `UnmarshalText([]byte) error` | `text: true` 时的 path/query/header |
完整参考 [示例 ID 类型](examples/compatibility/ids/id.go)。CLI 校验声明并生成代码,不执行项目 Go 代码;启动时校验方法是否齐全,缺失会明确失败。
输入可选 `string`、`bool`、`int32`、`int64`、`float64`,用 `|` 声明多个允许的 JSON 类型;输出只声明一个。框架先校验 JSON 类型和数字范围,再调用项目转换方法。字符串是否是合法 ID、是否允许空串或负数由项目方法决定,不能把数字溢出静默截断。path/query/header 实际传输文本,OpenAPI 对这些位置描述 string;query 切片通过重复键传递。
普通字段不受注册影响,普通 `int64` 仍拒绝 JSON 字符串。没有全局注册表,也不会自动给所有 ID 放宽规则。标量可嵌套、放入切片/map,`*BusinessID` 允许 null;缺失默认必填,`optional`/`omitempty` 可选。标量的额外约束放在转换器中,目前不叠加 `binding`、default/options/range 等标签。
请求 OpenAPI 按 `string | int64` 生成 `anyOf`,响应按 `string` 生成,避免把输入兼容误描述为输出也有两种类型。输出方法必须遵守声明,框架不会每次重新验证响应;项目需为自定义类型测试边界行为。
不使用 `.api` 也可显式注册:
```go
binder := contract.MustNew[CreateReq](
contract.ScalarOf[ids.ID]("string|int64", "string", true),
)
// binder.Bind(c.Request(), httpx.RouteParams(c.Context()), &req)
```
## 可运行示例与验收
```sh
go run ./examples/compatibility
curl -i http://127.0.0.1:8080/legacy
curl -i -X POST http://127.0.0.1:8080/echo \
-H 'Content-Type: application/json' -d '{"id":9223372036854775807,"data":{"hello":true}}'
curl -i -F file=@README.md http://127.0.0.1:8080/upload
curl -OJ http://127.0.0.1:8080/download
```
完整契约:[compatibility.api](examples/contracts/compatibility.api)。生成项目起初的手写 hook 仍为 501;真实实现与原文 HMAC 验签回归见 [消费者 fixture](cmd/go-omni/apigen/testdata/compatibility)。
```sh
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./cmd/go-omni/apigen -run Compatibility
go test -race ./httpx ./contract
```
这些是框架验收,不代表任何业务项目已经迁移完毕;业务的路由覆盖、响应兼容、权限、资金事务和数据库行为需要业务自己的回归。

186
MODULES.md Normal file
View File

@@ -0,0 +1,186 @@
# 业务基础模块
第二批的共享 Redis 中间件、可靠队列、多平台存储及其他模块见 [扩展模块](ADVANCED.md)。下文的单进程约束指本页原有默认实现。
这批能力从 `v0.7.0-preview` 提供。统一示例见
[examples/modules](examples/modules/README.md)。业务入口继续使用 `omni.New`,中间件依赖
`httpx`,不依赖具体服务端网络实现。各模块按需创建、注入 Service,再注册到应用关闭流程。
## 请求追踪与基础指标
`omni.New()` 的默认链包含 RequestID、访问日志(可配置开关)和 Recovery;无需重复挂载。业务通过 `c.Context()` 传递 requestId,`logx.FromContext(ctx)` 取得该 App 的 Logger。手动组装 httpx 链时,也可使用 `middleware.RequestID("X-Request-ID")`、`middleware.AccessLog()` 和 `middleware.Recovery()`。
轻量进程指标直接使用 `metrics`,无需 OTel collector:
```go
// import omni "git.yuntuo.hk/k2safe/go-omni"
// import "git.yuntuo.hk/k2safe/go-omni/metrics"
app := omni.New()
measurements := metrics.New()
app.Use(measurements.Middleware())
app.GET("/metrics", measurements.Handler())
// 也可以直接读取 measurements.Snapshot()。
```
记录请求数、失败数、处理中数量、累计耗时与 HTTP 状态分布;返回的是 JSON 快照,并非 Prometheus 文本格式。指标接口是否暴露以及访问权限由项目配置。需要 HTTP/SQL/Redis spans 和标准 OTel exporter 时,使用 [telemetry 示例](examples/platform/main.go)。需要跨域时配置 `server.cors`,由框架在路由与鉴权前处理预检;默认关闭。已有代理 CORS 可继续使用,避免两处重复设置。见 [CORS 配置与示例](cors/README.md)。
## 缓存:cache 与 cache/redis
```go
store, err := cache.NewMemory(cache.MemoryConfig{MaxEntries: 4096, MaxBytes: 32 << 20})
if err != nil { return err }
application.OnShutdown(store.Close)
loader, err := cache.NewLoader(store, cache.LoaderConfig{MaxLoads: 128, Timeout: 5 * time.Second})
if err != nil { return err }
value, err := loader.GetOrLoad(ctx, "tenant:42:product:7", time.Minute,
func(ctx context.Context) ([]byte, error) { return repository.LoadProduct(ctx, "42", "7") })
```
- `Store` 统一 `Get/Set/Delete`,返回字节切片;业务可用 JSON 编解码。
- 内存缓存使用 LRU,同时限制条目数和 key/value 总字节数;字节上限不包含 Go 对象开销。
读写复制切片,过期时懒清理,没有每个条目一个 goroutine。
- TTL 为零表示不过期,负值拒绝;缺失返回 `cache.ErrMiss`。删除不存在的 key 成功。
- Loader 合并**同进程、同 key**的并发回源,最多同时运行 `MaxLoads` 个不同 key 的加载。
单个调用者取消只停止等待;共享加载有独立超时,加载函数必须响应 context。
超限返回 `ErrBusy`,缓存故障直接返回错误,不自动当成 miss 冲击数据库。
- 同 key 的调用必须具有相同数据含义、权限范围和 TTL;加载采用首个调用者的 context 值。
多租户 key 必须含租户标识。进程间合并、分布式锁、缓存更新与数据库事务一致性由业务另外设计。
Redis 适配器借用调用方创建的 go-redis 客户端和连接池:
```go
client := goredis.NewClient(&goredis.Options{
Addr: "127.0.0.1:6379", PoolSize: 16, MaxRetries: -1,
DialTimeout: time.Second, ReadTimeout: time.Second,
WriteTimeout: time.Second, PoolTimeout: time.Second,
ContextTimeoutEnabled: true,
})
// 启动时先使用有截止时间的 context 执行 client.Ping(ctx)。
store, err := rediscache.New(client, "billing:") // cache/redis 的导入别名
if err != nil { _ = client.Close(); return err }
application.OnShutdown(client.Close)
// store 可直接传入 cache.NewLoader。
```
连接失败与 Redis 错误保持原样;只有 Redis nil 映射为 `ErrMiss`。Redis TTL 精度为毫秒,
正的亚毫秒 TTL 提升到 1ms。适配器不负责关闭借用的客户端,Redis 服务端容量策略由部署配置。
连接池和超时配置参考 [go-redis 官方生产使用说明](https://redis.io/docs/latest/develop/clients/go/produsage/)。
## 出站 HTTP:httpc
```go
client, err := httpc.New(httpc.Config{
Proxy: httpc.ProxyConfig{Mode: httpc.ProxyDirect},
Timeout: 3*time.Second, DialTimeout: time.Second,
TLSHandshakeTimeout: time.Second, ResponseHeaderTimeout: time.Second,
MaxConnsPerHost: 32, MaxIdleConnsPerHost: 16, MaxIdleConns: 64,
IdleConnTimeout: 60*time.Second, MaxResponseBytes: 1<<20,
})
if err != nil { return err }
application.OnShutdown(client.Close)
err = client.JSON(ctx, "GET", serviceURL, nil, &result,
httpc.WithHeader("Authorization", "Bearer "+token),
)
```
启动时创建并复用 Client。代理可选环境、直连、固定地址;不配置时保留标准库原代理策略。JSON 支持单次请求头和响应限额,不保存 Client 全局 Token。新阶段超时与空闲连接参数可选,0 沿用默认。
成功响应读取有界;output=nil 流式丢弃,不整体缓存。小错误体只在大小/时间预算内尝试读完以复用连接;未知或大错误体及时关闭。Do 保留标准流式 Request/Response,由调用者限量读取并关闭 body。Close 拒绝新调用并关闭自有池空闲连接,项目先排空在途工作。
自定义 Transport 借用、不修改、不关闭;不能同时指定池/代理/阶段选项。总超时持续覆盖 HTTP 等待、重试和读取 body,但不抢占 JSON 编解码或不遵守 context 的自定义代码。MaxRetries 默认 0,只对可重放 GET/HEAD/OPTIONS 重试;3xx 不跟随。标准库和借用 Transport 自身仍可能重放,不能据此承诺业务只执行一次。
完整参数、NO_PROXY、TLS、安全日志、重试、签名兼容说明见 [httpc 指南](httpc/README.md),可运行 [HTTP Client 示例](examples/httpClient/README.md)。
## 请求保护:middleware
```go
limiter, err := middleware.NewRateLimiter(middleware.RateLimitConfig{Rate: 20, Burst: 40})
if err != nil { return err }
idempotency, err := middleware.NewIdempotency(middleware.IdempotencyConfig{
Scope: func(c *httpx.Context) (string, error) {
principal, _ := auth.FromContext(c)
return principal.UserID, nil // 多租户应用应组合租户和用户 ID
},
})
if err != nil { return err }
api := application.Group("/api", middleware.Timeout(3*time.Second),
auth.RequireAuth(authenticator), limiter.Middleware())
api.POST("/orders", createOrder, idempotency.Middleware())
```
限流为进程内令牌桶,`Rate` 为每秒额度,`Burst` 为突发容量。默认按已解析的可信客户端 IP,
可用 `Key` 按已认证身份分桶。额度不足返回 **429 + Retry-After**;key 数超过 `MaxKeys`
返回 **503**。满容量时不会踢掉活跃桶让攻击者绕过限流;空闲桶经过完整补充周期后才允许回收。
Timeout 设置协作式 deadline,不额外启动 Handler goroutine。业务及下游调用必须响应 context;
超时后尚未提交响应时返回 **504**,已经提交的响应保持原状态。它不能强制中断任意 Go 代码,
也不能替代网络层读写超时。不要在业务代码中异步持有 `httpx.Context`。
幂等保护适用于小型、非流式写接口:
- 必须先认证/鉴权,再执行幂等中间件;`Scope` 必须取自服务端验证过的身份。
GET/HEAD/OPTIONS 直接通过,写请求要求 `Idempotency-Key`,长度 1..128 个非空格 ASCII 字符。
- 同 Scope + key + 方法/URL/Content-Type/body 重放首次响应;内容改变返回 **409**。
正在执行或结果不可重放的相同请求也返回 **409**,不并发执行第二次。
- 默认最多 32 个处理中请求、256 条记录、16 MiB 保存的响应头/body,单次请求体 1 MiB、
响应 body 64 KiB。请求超限 **413**,容量满 **503**,响应超限 **500**。
- panic、5xx、超时、响应超限、Set-Cookie、Cache-Control:no-store 或保存容量不足时保留失败记录,
阻止在 TTL 内自动重执行业务。业务可能已经提交,调用方应查询实际结果后处理。
- 完成记录默认保留 10 分钟;在途记录不会因 TTL 到期而允许重复执行。
重放保留原响应 body、原 `requestId` 与原 `X-Request-ID`,增加 `Idempotency-Replayed:true`;
本次访问日志仍使用本次请求 ID。
- 记录仅在当前进程有效,重启、TTL 到期和多副本切换后均无法保证去重。支付、库存等关键写入
应在数据库事务内配合唯一业务键。该实现不提供跨进程 exactly-once,也不支持 SSE/流式/升级连接。
## 后台执行:task
```go
jobs, err := task.New(task.Config{Workers: 4, QueueSize: 128, TaskTimeout: 30*time.Second})
if err != nil { return err }
application.OnShutdown(jobs.Close) // 在注册数据库/缓存关闭之后注册,先排空任务
future, err := jobs.Submit(serviceContext, "refreshCatalog", refreshCatalog)
if err != nil { return err } // 满队列立即返回 ErrQueueFull
err = future.Wait(ctx) // 可选;不等待时,任务失败仍通过 OnError/logx 报告
err = jobs.Every("refreshCatalog", time.Minute, refreshCatalog)
```
固定 worker 和有界队列控制资源。任务保留提交 context;需要独立于 HTTP 请求执行时使用服务
context,或明确使用 `context.WithoutCancel` 保留日志关联值。任务仍受 Runner 的超时/退出控制。
任务函数与 `OnError` 回调应及时返回,尤其要响应取消;panic 被转为失败,worker 继续工作。
`Every` 使用固定间隔,第一次在一个间隔后执行;同一计划上轮尚未完成、排队或队列满时跳过。
默认最多 128 个计划,名称不可重复。`Stats` 提供 active/queued/completed/failed/skipped。
它是进程内任务执行器,没有任务持久化、失败自动重试、cron 表达式、跨副本选主或崩溃恢复。
`Shutdown(ctx)` 停止接收和调度,排空已接受任务;截止时间到后取消任务并及时返回。
忽略取消的任务不能被 Go 强杀,可能继续运行。`Close` 默认给排空 10 秒。
## 增加业务模块:go-omni add
```sh
go build -o /tmp/go-omni ./cmd/go-omni
/tmp/go-omni add orderItems --dir /path/to/billing
```
生成 `internal/orderItems/{module.go,model,repository,service,handler,README.md}`,包含 name/createdAt
示例模型、内存 CRUD、输入校验、错误映射及可注入 Repository。模块名使用 lowerCamelCase。
在现有启动代码中导入模块后调用 `orderItems.Register(application, repository, authMiddleware)`。
脚手架不自动修改现有路由、main、go.mod 或数据库,已有目录不会覆盖。
内存 Repository 仅为可运行示例:重启丢数据,列表未分页;正式业务应实现自己的持久化 Repository。
## 验证入口
```sh
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./...
go vet ./...
go build ./...
python3 scripts/verifyModules.py --goproxy https://goproxy.cn,direct
```
Linux runner 顺序运行两个容器:全量 race/vet/build/独立生成项目,随后真实 Redis 适配验证。
每次最多一个容器,限制 2 CPU、2 GiB(不额外使用 swap)、256 pids;Redis 测试容器禁外网,
只用内部 loopback,不发布端口。Redis 集成二进制为静态非 race,适配器单测在全量 race 中覆盖。
结束清理本轮容器,并记录提交、源文件摘要、镜像 ID、日志摘要和原有容器是否仍运行。
本轮通过结果和原始证据见 [业务模块验收](performance/modulesAcceptance.md)。

View File

@@ -1,20 +1,89 @@
# GoOmni 性能基线
这份基线用于比较未来自研网络层,不代表生产容量承诺。测试只测进程内请求分发和 GoOmni 请求链,不包含真实 TCP、TLS、数据库或业务存储延迟。
这轮测量显示 native 与标准库处于相同吞吐量级,在本机短时测试中,native 的空闲长连接堆内存更少。当前 native 仅实现 HTTP 的部分能力,尚不能据此判断生产容量或替代完整标准库服务端。
运行:
## 运行方式
```sh
# race、vet、build,以及进程内和真实 TCP benchmark
python3 scripts/verifyPerformance.py
# 另加独立服务进程的并发、延迟和空闲长连接内存矩阵
python3 scripts/verifyPerformance.py --load --output /tmp/goOmniPerf.json
# 只运行独立进程矩阵,默认每档 2 秒、3 轮
go run ./internal/perfcheck --duration 2s --count 3 --output /tmp/goOmniPerf.json
# 在 Linux Docker 容器内固定运行 race、vet、build 和 TLS 压测
python3 scripts/verifyLinux.py --load --tls --duration 30s \
--concurrency 128 --idle 512 --count 1 \
--output performance/linuxSoakTLS.json
# 发布候选验收可把持续流量延长到 30 分钟
python3 scripts/verifyLinux.py --load --tls --duration 30m \
--concurrency 128 --idle 512 --count 1 \
--output performance/linuxSoakTLS-30m.json
# Linux 上用 Nginx 反向代理接入 native TLS 服务,检查 Keep-Alive、超时和优雅关闭
python3 scripts/verifyProxy.py
```
本次记录环境:Go 1.25.0、darwin/arm64、Apple M2 Max;每项连续运行 3 次:
可通过 `--concurrency 1,32,128` / `--idle 128,1024` 调整矩阵;传空字符串可跳过对应场景。工具只监听本机随机端口,每个场景启动独立服务进程,并在结束后关闭客户端、检查连接排空、关闭服务。响应状态、完整响应体、requestId 或传输错误均计入失败,不能把快速返回的错误页算成成功吞吐。
```text
BenchmarkTransportRouteDispatch/nethttp 568–671 ns/op 1344 B/op 13 allocs/op
BenchmarkTransportApplicationRequest/nethttp 2017–2208 ns/op 3091 B/op 34 allocs/op
```
Linux 验收脚本使用 `golang:1.25` 容器,固定执行 `go test -race ./...`、`go vet ./...`、`go build ./...`,再按参数运行 perfcheck。perfcheck 在 Linux 读取 `/proc/self/fd`,报告连接建立期间的文件描述符峰值以及回收后的数量;同时记录服务端堆、goroutine、连接数和累计 accept 数。30 秒报告用于快速回归,30 分钟命令用于发布前持续压测。
`BenchmarkTransportRouteDispatch` 只覆盖标准库路由、路径参数和 `http.Handler` 调用;`BenchmarkTransportApplicationRequest` 还包含 GoOmni Context、RequestID、统一 JSON 响应和默认中间件链。
## 2026-09-21 本机测量
比较新网络层时,应使用相同 Go 版本、机器和 benchmark 命令,并同时运行 `-race` 全量测试。性能下降需要结合分配次数、取消传播、错误响应和业务契约结果一起判断。
环境:Go 1.25.0、darwin/arm64、Apple M2 Max、GOMAXPROCS=12。网络实现来自 `3d5f225` 工作树(native 与 v0.6.0-preview 相同);本次新增测量工具,不修改服务端实现。原始测量值见 [tcpBaseline.json](performance/tcpBaseline.json)。报告时间为 UTC,北京时间是 9 月 21 日。
每个并发档位持续约 2 秒、重复 3 轮,每轮交替标准库/native 的先后顺序。下表为各指标的三轮中位数;全部流量场景共完成 **2,660,828** 次请求,错误 **0**。另有 128 / 1,024 条空闲长连接的建立、保持和回收测试。
| 并发请求 | 标准库成功请求/秒 | native 成功请求/秒 | 标准库 P95 | native P95 | 标准库 P99 | native P99 |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| 1 | 19,731 | 21,497 | 0.074 ms | 0.070 ms | 0.139 ms | 0.119 ms |
| 32 | 90,099 | 88,812 | 1.016 ms | 0.983 ms | 1.507 ms | 1.507 ms |
| 128 | 112,422 | 112,105 | 3.277 ms | 3.408 ms | 4.719 ms | 4.981 ms |
128 并发时,两者吞吐差约 0.3%,native 的尾延迟略高。基于这组结果,暂时没有理由仅为健康接口吞吐就改成手写事件循环。
| 空闲 Keep-Alive 连接 | 标准库新增堆内存 | native 新增堆内存 | 标准库约每连接 | native 约每连接 |
| --- | ---: | ---: | ---: | ---: |
| 128 | 1.275 MiB | 0.658 MiB | 10,444 B | 5,389 B |
| 1,024 | 9.871 MiB | 5.137 MiB | 10,108 B | 5,260 B |
1,024 条空闲连接时,native 的新增堆内存约少 48%。这是**服务端 Go 堆内存增量**,不包括 goroutine 栈、进程其他内存和内核 socket 缓冲区,不能解释为整个进程内存减半。同档服务端 `StackInuse` 分别约为标准库 8.75 MiB、native 8.69 MiB,原始报告单独记录。
连接断开后,两者连接数都回到 0,goroutine 分别回到 2 / 3 的初始基线。一次 GC 后仍有对象池缓存;第二次 GC 后,总堆内存中位数回到约 2.10 / 2.08 MiB(启动基线约 1.54 MiB)。堆内存不会立刻精确回到启动数值,不把缓存保留解释为泄漏,也不把这轮短测解释为长期无泄漏证明。
128 并发时,**服务端单独累计分配**约为标准库 4,454 B/请求、47 次/请求,native 4,297 B/请求、55 次/请求。native 的分配字节较少、次数较多;具体来源需用分配 profile 确认,不能仅凭总量归因于 HTTP 桥接或 JSON。
## 统计边界
- 同一台机器上,压测客户端和服务端是两个独立进程。两者使用同一健康接口、相同小型 JSON 响应、固定 `requestId=perfCheck` 和 Keep-Alive;不启用访问日志、TLS 或数据库。
- 并发模型为闭环:每个 worker 等上一个请求完成后发下一个。实际经过时间包含最后请求排空;吞吐只计算成功响应。不能据此推断开放到达流量下的排队或过载表现。
- 延迟从客户端发起请求到完整响应校验完成,包含失败请求。直方图使用每个 2 的幂区间内 16 档,报告桶上界,舍入误差至多约 6.25%。小幅差别不应过度解释。
- 服务端 `runtime.MemStats` 通过独立 stdin/stdout 控制通道读取,客户端内存不计入。累计分配差值包含少量采样开销。堆峰值每 200 ms 采样,可能错过瞬时峰值;这些指标不是进程 RSS。
- Linux 文件描述符来自服务端子进程的 `/proc/self/fd`,包含标准输入输出、监听 socket 和连接;macOS 等没有该路径的平台会省略该字段。回收后的数量应回到启动附近,不能代替内核 socket 缓冲或 RSS 测量。
- 空闲场景在所有连接完成健康请求后做 GC 再采样。新增堆内存包括连接、缓冲区及分摊的运行时开销。`after` 为客户端断开并排空后一次 GC,`afterReclaim` 为第二次 GC,用于观察对象池缓存释放。
- native 配置上限为 8,192 连接 / 4,096 活跃请求;本轮并发低于上限,未验证过载保护容量。标准库没有同样的并发闸门;比较包括各自真实实现的开销。
- 可用 `python3 scripts/verifyPerformance.py --load --tls --duration 2s` 运行 TLS 版本的独立进程矩阵。该模式在每个子进程生成短期自签名证书,客户端仅用于本机基准验证;本报告的历史表格仍是无 TLS 数据。
## benchmark 口径修正
## TLS 验收记录
macOS arm64 本机的 5 秒 TLS 矩阵见 [tlsBaseline.json](performance/tlsBaseline.json):1 / 32 / 128 并发和 128 / 1,024 条空闲连接均为 0 错误;native 在 128 并发约 114,232 req/s,1,024 条空闲 TLS 连接的服务端堆增量约 8.94 MiB,连接关闭后 goroutine 回到基线。
Linux arm64 容器使用 Go 1.25.14、GOMAXPROCS=4 运行 3 秒矩阵,见 [tlsBaselineLinux.json](performance/tlsBaselineLinux.json):1 / 32 / 128 并发和 128 / 512 条空闲连接均为 0 错误;native 在 128 并发约 99,876 req/s,512 条空闲 TLS 连接的服务端堆增量约 4.47 MiB,连接关闭后 goroutine 回到基线。容器验证同时通过全量 race、vet、build。
Linux arm64 的 30 秒高并发回归见 [linuxSoak.json](performance/linuxSoak.json) 和 [linuxSoakTLS.json](performance/linuxSoakTLS.json):两份报告均使用 128 并发流量和 512 条空闲连接,所有场景错误为 0;native 非 TLS 约 146,614 req/s、TLS 约 149,720 req/s,空闲连接堆增量分别约 2.57 MiB 和 4.47 MiB,FD 均由 7 增至 519 并回到 7,goroutine 均回到启动附近。
目标 Linux amd64 服务器的 30 分钟 TLS 验收使用 1 个 CPU、1 GiB 内存的隔离 Docker 容器完成:net/http 约 11,865 req/s,native 约 13,098 req/s,两个流量场景均为 0 错误;native 的 P95/P99 为 44.040/52.429 ms。512 条空闲 TLS 连接时,native 服务端堆增量约 4.50 MiB(约 9,215 B/连接),文件描述符由 7 增至 519 后回到 7,goroutine 和连接均正常回收。原始报告保存在服务器的 `performance/linuxServerSoakTLS.json`。
`BenchmarkTransportTCPApplicationRequest/{nethttp,native}/concurrency{1,32,128}` 使用同一测试入口、客户端、应用与响应检查。原先独立的 `BenchmarkNativeApplicationRequest` 已合并,避免两个入口配置漂移。`verifyPerformance.py` 会一并运行。
并行 benchmark 的 **`ns/op` 是总经过时间除以请求数,反映吞吐成本,不是单请求延迟**。`B/op` / `allocs/op` 统计同一进程中的客户端与服务端总分配。此前报告的 native 15.4–16.7 µs/op、约 7.8 KB/op、100 allocs/op 应按这个含义理解,不能作为服务端延迟或独立内存指标。
`BenchmarkTransportRouteDispatch` 和 `BenchmarkTransportApplicationRequest` 继续保留,分别测进程内路由分发和完整框架处理链;不包含 TCP,不能直接与上述网络延迟比较。
后续通过 30 分钟命令完成发布前持续压测,并在目标反向代理和部署环境继续观察内核 socket 缓冲、FD 上限与真实业务流量;再通过 profile 决定是否优化对象分配或事件状态机。

81
PROGRAM.md Normal file
View File

@@ -0,0 +1,81 @@
# 通用 Go 项目模板
从 v0.28.0-preview 起,`project new/generate/check` 提供不绑定协议与业务的工程骨架。默认只有配置、日志、依赖装配、生命周期和普通 Go 运行入口;不生成 `.api`、HTTP Handler、Swagger、业务任务,也不监听端口、连接数据库或队列。不增加第三方依赖。
## 创建与运行
```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.1-preview
go-omni project new demo --module example.com/demo
cd demo
go mod tidy
go run . -check-config
go run . # 默认等待 SIGINT / SIGTERM
go build -o program .
```
目标目录必须为空;未指定 module 时使用目录名。远程安装的 CLI 将自己的固定版本写入 go.mod,无本地替换。当前通用模板要求框架 v0.28.0-preview 或更新版本。源码开发可显式 `--framework-dir /path/to/go-omni`,不会从当前工作目录隐式选择本地框架。`--framework-version` 可指定兼容版本。框架版本、依赖更新由项目修改 go.mod,重新生成不会代为升级依赖。
常驻程序、一次性程序用同一份骨架:项目自己的 `Run` 决定何时完成。完整生成项目见 [examples/program](examples/program/README.md),不需要外部服务。
## 文件职责
| 路径 | 维护方与职责 |
| --- | --- |
| `main.go` | 生成器:信号、启动、运行入口监督、退出状态与日志收尾 |
| `internal/config/loadGen.go` | 生成器:统一配置加载 |
| `internal/svc/runtimeGen.go` | 生成器:依赖初始化及失败收尾 |
| `internal/config/config.go` | 项目:Config、Default、Validate;初始只含 log/lifecycle |
| `internal/config/loader.go` | 项目:按需注册额外文件格式 |
| `etc/config.yaml` | 项目:配置与显式环境引用 |
| `internal/svc/serviceContext.go` | 项目:依赖字段、Init、资源与后台工作登记 |
| `internal/run/run.go` | 项目:Run 的实际实现 |
| `go.mod`、README、.gitignore、其他 Go 文件 | 项目:生成器不覆盖 |
| `.goOmni/projectManifest.json` | 生成器:工程身份、模板版本、生成文件哈希;应提交 Git |
配置遵守 [CONFIG.md](CONFIG.md):默认值 → 文件显式 `${NAME}` / `${NAME:-fallback}` → 类型绑定 → 校验,没有隐式环境覆盖。JSON/YAML/XML 内置;`-config ""` 只用默认值。`-check-config` 不打开日志文件、不初始化依赖、不调用 Run。日志通过独立实例传给 svc,并注入 Context/WorkContext;不替换进程级默认日志实例。
## 运行与退出
```go
func Run(ctx context.Context, deps *svc.ServiceContext) error {
// 一次性程序可完成处理后直接 return nil。
// 常驻程序收到 ctx.Done() 后停止自己的主流程。
<-ctx.Done()
return nil
}
```
`nil` 表示正常完成,返回 error 表示运行失败。后台组件用 `RequestShutdown(err)` 报告致命故障,`RequestShutdown(nil)` 请求正常停止。框架不替项目重试、重新调度或重启任务。
依赖在 `svc.Init` 中装配:创建后立即用 `OnClose` 登记清理,后台组件同时登记同名 `OnStop` 和 `OnDrain`。所有登记应在 Init 返回前完成;Run 不得并发追加生命周期登记。组件何时接收工作由项目决定,停止后不得重新开始领取或启动未登记的工作。
- `Context()` 是提前停止通知,用来停止主流程或接收新工作。
- `WorkContext()` 在正常排空期间仍有效,用于已接受的工作。
- `OnStop` 停止接收新工作,不关闭处理中的工作仍依赖的资源。
- `OnDrain` 返回 nil,必须意味着组件处理、确认和 defer 收尾均已结束。仅返回 `ctx.Err()` 不能证明结束。
- 全部停止钩子先于全部排空钩子;同阶段并行。有先后依赖的组件在一个钩子内显式排序。
- 全部工作确认结束后,资源按 `OnClose` 登记的反序释放,最后关闭入口拥有的日志。
生成入口自动监督 Run 本身,以 `go-omni/project/run` 保留名称登记完成屏障。收到信号、Run 返回或报告故障后开始收尾,同时等待 Run 及其他已登记工作。Run 不应等待资源关闭:资源关闭发生在 Run 完成之后。
初始化失败同样收尾已创建的资源和已启动的工作。Init/Run 内 panic 被转为失败,安全收尾后进程以非零状态退出;任意自建 goroutine 的 panic 仍由项目负责处理。Run 不响应取消或其他工作无法确认结束时,`lifecycle.stopTimeout/drainTimeout/cancelTimeout` 限定等待窗口,返回 `ErrResourcesRetained`,跳过共享资源和日志关闭,以失败状态退出。取消后的工作不能被 Go 强制终止,保留资源不代表工作已经完成。SIGKILL 和进程崩溃不会执行退出钩子。
详见 [生命周期契约](lifecycle/README.md)。现成组件必须按其真实停止/完成语义适配,不能把某个 `Run/Close` 返回直接视为业务结束。
## 生成检查与兼容
```sh
go-omni project generate --dry-run
go-omni project generate
go-omni project check # 等价于 project generate --check
go test ./...
```
生成器不执行项目 Go 代码、不下载依赖。check/预览只读,不创建锁文件;生成时使用独立项目锁、路径和哈希预检。生成文件被手工修改会报冲突,不提供强制覆盖。删除的生成文件可补回;缺失的项目所有文件必须显式恢复,避免把删除的业务文件当成空白骨架重建。
每个文件通过原子替换写入,普通写入失败回滚此前变更;整个目录不具备崩溃事务性。生成期间不要同时手工编辑。发现遗留 project.lock 时,先确认没有生成进程再手工移除。
check 检查模板漂移和必要文件,不检查业务正确性,也不能替代 Go 编译与测试。新的模板版本会重新生成受管文件;不自动合并项目所有文件或更新 go.mod。未知的新清单版本会要求使用兼容 CLI。
原有 `new` / `api new` 及 API 工程行为保留。通用工程使用自己的清单和命令,不能交给 `api generate` 或 `add` 转换;新增业务包直接写普通 Go。从 v0.28.1-preview 起,`workspace generate/check` 可以同时处理 API 与通用工程。清单可填写 `kind: api | project`,省略时按生成清单识别;format 对通用工程显示 SKIP。见 [完整混合示例](examples/mixedWorkspace/README.md)。各 module 仍独立,workspace 命令不维护 go.work 或模块依赖。

238
PROJECT.md Normal file
View File

@@ -0,0 +1,238 @@
# API 生成项目与文件维护边界
通用 Go 工程使用独立的 `project new/generate/check`,见 [PROGRAM.md](PROGRAM.md)。本文说明 API 契约工程。
v0.28.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
## 创建和日常开发
```sh
# 在存放业务项目的目录执行,目标目录必须为空。
go-omni new --module example.com/hello ./hello
cd hello
go mod tidy
go run .
go build .
```
在项目目录使用同版 CLI:编辑 `api/service.api`,运行 `go-omni api generate`,填写 `internal/logic` 的业务方法。增加接口、分组或具名中间件会自动更新接入代码,无需修改入口。`--dry-run` 预览;`--check` 只读检查,包括 main、HTTP 接入和 OpenAPI。API 文件通过相对 import 拆分模块。
## 契约变更提示
生成器在 manifest 中保存上次成功生成的接口快照,比较请求/返回类型、嵌套 DTO 字段与标签、自定义 scalar、路径、body 模式/限额和流式输出类型。只提示受影响的接口,并标出业务文件;删除或移动接口会提醒整理保留的旧实现。注释、文件排版、Logic 参数名与导入别名不会被当成接口变化。
例如把 `GreetReq` 改名为 `HelloReq`,旧业务方法尚未修改时:
```text
ERROR [logicSignature] internal/logic/greet/greet.go:12 (GET /api/greet/:name): ...
before: Greet(context.Context, *types.GreetReq) (*types.GreetResp, error)
after: Greet(context.Context, *types.HelloReq) (*types.GreetResp, error)
```
| 命令 | 变更提示与检查行为 |
| --- | --- |
| `api generate --dry-run` | 显示计划、契约变化和业务签名问题,不写文件;诊断本身不让预览失败 |
| `api generate` | 更新生成文件和契约快照,保留业务代码,并显示需要处理的诊断;生成成功不代表业务已通过编译 |
| `api generate --check` | 只读检查;生成产物过期或发现明确的 Logic 签名/语法/重复方法错误时返回非零退出码 |
| `api validate` | 只验证 `.api`,不检查项目 Go 实现 |
`REVIEW` 提醒复查业务语义;`ERROR` 表示已发现源代码问题。生成后,旧签名仍会在后续 `--check` 中报错,直到你改好;DTO 字段变化的 REVIEW 与上次生成快照比较,生成后不持续要求确认,也不会替你修改业务。业务方法移到同组其他 Go 文件时仍能定位。
这是轻量源码分析,不运行项目 init、不执行 Go 命令、不下载依赖。项目自定义类型别名、嵌入方法或 build tags 等无法可靠判断时,会给 REVIEW 并交给 Go 编译器验证;不把这些情况强行判为签名错误。它不检查方法体是否引用已删除字段,也不证明业务逻辑正确。CI 中应同时执行 `go-omni api generate --check` 和 `go test ./...`,按项目实际构建参数测试。当前版本首次接手缺少快照的 manifest v2 时,会明确提示建立基线,不猜测旧契约;Logic 签名检查仍会执行。
生成选项、公共字段组合和可选 Go 拷贝工具,详见 [生成能力指南](GENERATION.md)。
## 哪些文件由用户维护
| 文件 | 首次生成后由项目维护 |
| --- | --- |
| `api/*.api` | 路由、DTO、具名中间件、响应模板、请求/响应模式 |
| `etc/config.yaml` | 运行参数;密钥通过环境变量提供 |
| `internal/config/config.go` | 自定义配置字段、默认值与校验 |
| `internal/config/loader.go` | 需要额外文件格式时注册 Decoder |
| `internal/svc/serviceContext.go` | 依赖字段和初始化,持有加载后的 Config |
| `internal/svc/options.go` | AppOptions、GlobalMiddleware;注册 Go 错误目录和全局扩展 |
| `internal/svc/databaseOptions.go` | 可选 MySQL 驱动与项目启动检查扩展 |
| `internal/svc/storage.go` | 选用存储时生成,之后保留 |
| `internal/logic/<group>/*.go` | 业务方法,使用标准 context.Context |
| `internal/repository/<group>/*.go` | 数据访问;默认不补空骨架,可选 group 模式 |
| `internal/middleware/*Factory.go`、`dependencies.go` | 中间件工厂与依赖小接口,svc 实现访问器 |
| `omni.gen.json` | Repository 和中间件生成选项;不再声明结构映射 |
| 可选的 Go 转换函数 / copyx 调用 | 只有独立类型确实需要转换时维护,见 [copyx](COPY.md) |
| 项目 Go 错误包 | 自行维护 Catalog 和常量,可跨服务共用 |
| 自定义标量文件、`*Bind.go`、`*Manual.go` | 只有显式选择相应扩展时才生成/使用 |
| 项目测试、迁移、部署脚本、go.mod/go.sum | 项目所有,生成器不重置其内容 |
以上骨架只补缺失文件,既有内容按字节保留。删除已废弃接口后,对应业务文件也不会被自动删除;由开发者整理编译器指出的失效类型或签名。
生成器持续管理:`internal/config/loadGen.go`、`internal/svc/runtimeGen.go`、`main.go`、`internal/handler/*Gen.go`、分组的 `handlersGen.go`、`internal/types/*Gen.go`、中间件 `registryGen.go`、`docs/openapi.json`、`.goOmni/apiManifest.json`。如果在 `.api` 里选择声明错误,还会管理 `internal/apierrors/errorsGen.go`。文件标注 DO NOT EDIT;手改会被拒绝覆盖,需先移走自定义内容。
Logic 实例在注册时创建并由请求并发复用;请求数据用方法参数和局部变量传递,不存入共享 Logic/svc 字段。
普通 Handler 不需要维护。目录内可以同时有生成文件和单独的手写扩展文件,所有权按文件区分。没有额外 server 包或第二层 service。
## 依赖与启动扩展
`svc.New(ctx, logger, cfg)` 初始化依赖,可返回错误;Logger 由入口创建并借用。New 本身由生成器维护;新增客户端时扩展配置和 svc 的 `Init(ctx) error`:
```go
client, err := newClient(c.Config.Client)
if err != nil { return err }
c.Client = client
c.OnClose(client.Close) // 创建成功后立即登记,后续失败也能释放
```
ServiceContext 嵌入 `*lifecycle.Runtime`;框架维护锁、清理栈和失败回滚,项目不再复制 Close/OnClose 实现。OnClose 按登记的相反顺序执行;Close 幂等。项目不要重复关闭同一资源,借用的 Logger 也不应由 svc 关闭。构造器在返回错误前若创建了尚未交付的资源,该构造器自己负责释放。
错误注册写在 `internal/svc/options.go`,错误表可以来自任意项目共用包:
```go
func (c *ServiceContext) AppOptions() []omni.Option {
return []omni.Option{omni.WithErrors(apierrors.Catalog())}
}
```
业务调用 `omnierrors.ByCode(apierrors.CodeUsernameInvalid)`,可接 `WithParams` / `WithCause`。无需在 `.api` 重复错误目录。未声明独立错误目录的路由继承 App 目录;独立目录不与 App 合并。普通 `.api` 编译不执行 Go;可在 workspace 显式配置 Catalog 函数,统一导出共享文档元数据,也可沿用 `api/omni.errors.json`,详见 [元数据导出](DEVELOPMENT.md#字段文档格式化go-错误目录导出)。
`GlobalMiddleware()` 提供全局中间件;`Middlewares` 映射提供 `.api` 声明的具名中间件。全部显式调用,不依赖项目 init 注册,也不要求 svc 导入 handler。框架请求链按原有次序运行。
生成入口执行:配置加载/校验 → 日志 → svc.New → 生成的 handler.NewApp(应用 AppOptions、响应策略、校验最终 HTTP 配置、全局中间件、全部路由)→ App.RunContext → 依赖关闭 → 日志关闭。初始化失败、接入配置失败和监听失败也清理资源。
最终配置校验包含自定义响应编码器与错误目录的组合。码类型/映射冲突、编码器已绑定另一份目录等问题在创建 App 前返回错误,进入入口的清理流程,不等到构造 App 时才 panic。
日志在 AppOptions 前初始化,其配置应放在 Config 中。退出信号先触发 HTTP 排空;达到 shutdownTimeout 后取消请求并关闭连接,再按 `http.shutdownCancelTimeout` 等待处理函数收尾。仍未结束时保留依赖并返回 `ErrResourcesRetained`,不会提前关闭数据库。请求仍依赖的后台任务/客户端不能直接绑定信号 context 提前关闭,应持有独立生命周期,使用 WorkContext 并登记成对的 OnStop/OnDrain,详见 [分阶段关闭](lifecycle/README.md#后台组件分阶段关闭)。业务必须响应请求 context 取消;忽略取消的 goroutine 无法保证在超时前结束。
## 依赖主动申请退出
从 v0.13.0-preview 起,生成的 svc 提供 `RequestShutdown(err)`。数据库客户端、节点租约、后台任务等发现不可恢复故障时,由项目决定是否退出:
```go
// 在 svc 的依赖监控回调中调用;err 是实际故障原因。
c.RequestShutdown(err)
```
这只是通知,立即返回,不等待 HTTP 排空,也不直接关闭数据库。生成入口停止接收新请求,等待在途请求完成;超过 `shutdownTimeout` 后取消请求并关闭连接,再等待 HTTP 取消收尾窗口。通过 `svc.Finish(runErr)` 执行后台停机;确认工作结束才关闭依赖与日志,无法确认时保留资源并非零退出。非空故障原因保留在返回错误链中,进程退出码为 1;`RequestShutdown(nil)` 或正常 SIGINT/SIGTERM 在没有其他故障时退出码为 0。
多个依赖可并发通知,关闭流程仅执行一次,首个非空故障原因保留。正常信号已开始排空时收到的故障也不会被清理动作覆盖。初始化期间的通知同样有效,不依赖 HTTP 监听已经启动。后台监控任务应通过 OnStop/OnDrain 停止并等待,再进入资源清理;关闭完成以后才报告的故障无法参与已经结束的进程结果。
`Lifecycle *lifecycle.Controller` 是 svc 持有的通用控制器,初始化时创建,生成入口使用它的 Context。该 Context 表示服务正在停止,不代表数据库已经关闭。不能把它当作在途请求的 Context,也不要在它取消时立刻关闭请求仍依赖的资源。发号器失去租约后拒绝发号等业务安全规则仍由项目实现。
配置加载入口 `internal/config/loadGen.go` 也由生成器维护。业务扩展 Config/Default/Validate;只有新格式才需要修改 NewLoader。JSON/YAML/XML、错误定位和旧 Load 迁移见 [配置指南](CONFIG.md)。
## 配置内置接口
生成项目在 `etc/config.yaml` 明确列出以下默认配置;可以独立关闭、修改路径,或让健康接口返回原始 JSON:
```yaml
server:
endpoints:
liveness:
enabled: true
path: /health/live
responseMode: app
readiness:
enabled: true
path: /health/ready
responseMode: app
openapi:
enabled: true
path: /openapi.json
```
`responseMode: app` 使用项目 App 的响应模板/自定义 Encoder。健康成功使用项目成功码和成功提示;未就绪的 `NOT_READY` 可在项目 Go 错误目录配置业务码和公开提示,无需在 `.api` 再声明。
健康探针的 HTTP 状态固定遵循探针语义:存活/就绪为 200,未就绪为 503;项目错误目录或自定义编码器不能把未就绪改为 HTTP 200。响应模板 `${status}` 同样使用探针状态。失败继续进入业务失败日志/指标,探针底层原因保留在错误链供日志使用,不直接返回给调用方。
`responseMode: raw` 返回 `{"status":"ready"}` 等健康数据,有依赖时包含检查名称、结果与耗时;不包装公共体。存活检查不执行依赖探针;服务开始退出后,就绪检查返回 503,存活仍可返回 200,直到监听关闭。OpenAPI 始终输出规范文档本身,不使用业务响应包装;关闭 HTTP 文档端点不影响生成 `docs/openapi.json`。
内置路由配置由项目文件明确设置,也可以使用 `${NAME:-fallback}` 引用;不再按字段名自动读取环境变量。Config/AppOptions 的最终有效配置决定是否注册。
关闭或改名后,旧路径不再由框架保留,项目可以在 `.api` 自己声明。启用的内置 GET 路由与业务路由相同或被业务参数/通配路径覆盖时,在创建 App、开始监听之前报错;不会覆盖业务路由。内置接口仍经过全局中间件;项目在鉴权中间件中明确决定这些路径的访问规则,框架不自动绕过鉴权。
<a id="从-v012-生成项目升级"></a>
## 从 v0.13 或更早版本升级
请按 [v0.14 定点迁移说明](DEVELOPMENT.md#v013-项目升级) 使用 `api upgrade`。原始模板可以自动升级;含业务修改的基础文件必须参考局部预览合并。旧 New/Close/OnClose 移到框架 Runtime,项目依赖初始化保留在 Init。不要继续向项目里复制生命周期锁和清理栈。
## App 默认响应格式
在 **入口** `.api` 声明一次:
```text
@app (
defaultResponse: CommonResponse
)
response CommonResponse (
codeType: int32
successCode: 0
errorCode: 9999
messageField: msg
dataMode: always
)
```
优先级:路由 `@response` → 分组 `@server(response: ...)` → App 默认。模板可以定义在 import 文件里,`@app` 只能在入口声明。重复声明、引用不存在的模板会报错;不会按文件顺序猜默认值。未声明 `@app` 时使用框架现有默认格式。
生成的 App 普通路由、404/405、未单独指定格式的生成路由共用默认模板。已匹配路由的中间件提前报错也使用路由策略。`@response raw` 的成功 JSON 不包装,错误沿用有效模板。
显式 `.api` 默认响应优先于 Config/AppOptions 中的响应配置,保持接口文档和实际响应一致。其他 AppOptions 继续生效;要改变格式就修改 `.api`。项目无需访问 handler 私有变量。
## 自动上传、回调与下载
以下声明生成完整 Handler,用户填写 Logic:
```text
@handler upload
@body (mode: multipart; maxBytes: 33554432)
post /upload returns (UploadResp)
@handler callback
@body (mode: raw; maxBytes: 65536)
@produces "text/plain"
post /callback
@handler download
@produces "application/octet-stream"
get /download (DownloadReq)
```
- 以下为未声明 multipart 字段的流式用法;声明式绑定见 [上传字段](DEVELOPMENT.md#声明式-multipart)。raw/流式 multipart 的 Logic 增加 `body *httpx.Body` 参数。`body.Raw` 是受路由上限约束的原始字节流;`body.Header` 是请求头副本。
- multipart 用 `body.Parts.NextPart()` 顺序读取,关闭每个 Part;不调用 ParseMultipartForm,不产生上传临时文件,也不把整个文件放入内存。每个字段/文件的业务要求由 Logic 校验。请求 DTO 可定义 path/form/header;`form` 仍是 URL 查询参数,不能用 json DTO 重复读取上传体。
- Parts 是 `*httpx.MultipartReader`,返回的 `*httpx.MultipartPart` 提供 Read、Close、Header、FormName、FileName;NextRawPart 可读取未做 quoted-printable 解码的部分。NextPart 自动关闭上一个未读完的 Part;Close 会报告读取错误。不要混用 Parts 与 Raw 读取同一个 multipart 请求。
- multipart 缺少结束边界、头部/内容损坏默认返回 `400 / INVALID_MULTIPART`;超过路由限额仍为 413,媒体类型/缺少 boundary 为 415。框架只提供通用标识和默认提示,项目可以在 Go 错误目录中配置自己的状态、业务码和文案。内部解析原因保留在错误链,不进入公开响应;Logic 自己返回的数据库等错误不会被统一改成 400。
- raw 数据不经过 JSON 解码。签名算法、密钥及回执内容由业务提供;同一个流只消费一次。若中间件先读取验签,项目必须显式使用受限缓冲/重放,生成器不会自动缓存大文件。
- `@produces` 使 Logic 返回 `(*httpx.Stream, error)`,其中 Content 是 `io.ReadCloser`,可选 Name 生成附件下载头。ContentType 可携带 charset,但必须与契约媒体类型匹配。Handler 负责关闭内容流,无公共体包装;写出后发生错误不会追加 JSON。
- Handler 在成功响应前检查剩余请求流,包含未读完的 multipart 部分、最终边界和请求体上限;已经遇到的 multipart 读取错误不会因业务忽略它而变成成功。读取失败和 413 不能撤回 Logic 已产生的外部副作用;重要业务应先调用 `body.Complete()` 确认完整读取/验证,再提交事务或发布文件。Complete 可重复调用。
- 自动 Handler 采用先完成输入、再发送输出的顺序;不要把正在读取的 body.Raw 直接当作 Stream.Content 返回。需要同时读写请求/响应的协议时使用手写扩展。
- 大文件的耗时仍受 HTTP 读写超时约束。按接口需要配置,框架不自动无限延长。
默认请求体上限为 1 MiB,按路由设置 maxBytes,不能通过统一放大全局限制替代。`@produces` 仅声明媒体类型,不保证业务返回的内容符合该媒体的语义。Range、SSE 刷新、特殊协议等需要时仍可显式选 `@handlerMode manual`;它是可选扩展,不是普通上传下载的前置条件。
## 旧结构处理
新生成器使用 manifest v2,不兼容原 settings/servicecontext/service 与 cmd/server 布局。已有旧目录会报错,不能通过覆盖 main 自动迁移。试验代码需保留时,在新目录生成,移动 `.api`、Logic、配置和 svc 扩展并调整导入;独立的 `cmd/migrate` 等工具由项目维护。不会静默覆盖手写 main 或删除无关命令。
`go-omni new` 与 `api new` 统一使用此结构。受管理项目通过 `.api` 增加模块,旧的 `add` 手动模块装配方式不适用于该结构。直接引用 go-omni 框架包的项目不受这些目录约定约束。
## 已有服务接入步骤
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.28.0-preview:
1. 先保留旧服务,整理实际路由及现有响应、鉴权和超时要求。在新目录执行 `go-omni new --api /path/to/service.api --module example.com/service ./service`;不对旧入口强制覆盖。
2. 在 `.api` 中选择 `@app(defaultResponse: ...)`,显式保留原项目的字段、成功码、错误码和 `dataMode`。需要独立回执的路由单独声明 raw/produces。普通路由、404/405 和提前返回的中间件一起验收。
3. 把配置放入 `internal/config` 和 `etc/config.yaml`;数据库、自定义 Connector、Redis、ID Client 等依赖放入 `internal/svc`,初始化成功后立即 `OnClose`。现有事务和迁移工具继续由项目维护;数据库扩展见 [database](database/README.md)。
4. 业务错误目录只在项目 Go 包维护,通过 `svc.AppOptions()` 返回 `omni.WithErrors(apierrors.Catalog())` 注册一次。需要继承 App 目录的路由不要重复声明独立错误目录。项目鉴权放入中间件,业务实现迁入 Logic/Repository;无需修改 main 或生成的 Handler。
5. 上传、原始请求体验签和下载优先使用自动 Handler;只有特殊协议才选择手写扩展。保留实际验签算法、流式处理、大小限制和存储权限,按 [迁移扩展](MIGRATION.md) 处理旧 Handler 与自定义标量。
6. 每个服务运行下面的检查,再用实际配置验证依赖连通、启动失败清理和正常退出。业务侧补充原有接口兼容、鉴权、事务回滚与实际上传下载回归;框架测试不能代替这些业务验收。
```sh
go mod tidy
go-omni api validate
go-omni api generate --check
go test ./...
go build .
go run .
```
后续增加接口修改 `.api` 并重新生成;增加依赖修改配置和 svc。将契约、生成文件、manifest 与业务代码一起提交。完整流程不需要项目另外维护启动器,也不需要读取 handler 的私有变量。

780
README.md
View File

@@ -1,224 +1,690 @@
# GoOmni
GoOmni 是一套面向业务复用的 Go 后端基础框架。当前默认使用只依赖标准库的网络适配器,业务代码只依赖 GoOmni 的 transport-neutral API,后续可以替换为自研网络层。
GoOmni 是面向多个项目复用的 Go 基础框架。通用工程使用 `project new` 生成配置、日志、依赖装配、生命周期和普通 Go 运行入口;需要 HTTP 接口时可选择 `.api` 工程生成,也可以在已有项目中按需引入独立模块。运行方式、业务类型和外部依赖由项目决定。
远程 module 地址是 `git.yuntuo.hk/k2safe/go-omni`。业务项目可以直接引用指定版本:
**v0.28.1 改动**:`workspace generate/check` 支持 API 与通用工程混合处理,可选 `kind: api | project`,省略时按生成清单识别。`format` 对通用工程显示跳过;检查只读,生成前统一预检。见 [混合工作区示例](examples/mixedWorkspace/README.md)。
**v0.28 改动**:新增独立的 `project new/generate/check`,默认无 API、监听端口、业务任务或外部服务配置;保护手写代码,并等待主运行入口和登记的后台工作结束后释放依赖。见 [通用工程指南](PROGRAM.md)和[可运行示例](examples/program/README.md)。
**v0.27 改动**:统一三种内置服务实现的默认/路由/硬请求体限额,配置冲突在启动前报错;停机以处理函数实际退出为资源清理前提,超时仍有请求时保留共享依赖。见 [HTTP 策略](transport/README.md#统一请求体限额)、[停机规则](lifecycle/README.md#http-请求与依赖的关闭屏障)和[可运行示例](examples/httpSafety/README.md)。
**v0.26 改动**:通用 [i18n](i18n/README.md),提供独立词典、语言匹配、回退、命名参数/条件/复数、可选 HTTP 与错误适配、检查/导出 CLI 及前端运行时。文案由项目维护;[完整示例](examples/i18n/README.md)可直接运行。CLI 与框架使用同版 v0.28.1-preview。同时修复 Go 1.22 下请求体限额错误的识别。
**从这份 README 开始接入。** 当前版本 **`v0.28.1-preview`**,通用工程采用根目录入口和 `config / svc / run`;API 工程使用 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
**v0.25 改动**:`database.InTx` 返回可识别的事务阶段与结果,保留业务/驱动及回滚错误链;提交报错保守标记为结果不确定,不等同于已回滚。[使用规则](database/README.md#事务阶段与结果)、[可运行故障示例](examples/transactions/README.md)。
**v0.24 改动**:新增可选 CORS,生成项目通过 `server.cors` 自动接入,支持预检、错误响应和明确的来源白名单;默认关闭。[配置与规则](cors/README.md)、[可运行示例](examples/cors/README.md)。
**v0.23 改动**:配置校验定位具体字段和安全原因;`httpc.JSON` 可选限制成功状态;`copyx.Check` 无需样本预检类型;`logx` 支持实例独立时区。[四项完整示例](examples/developerTools/README.md)。
**v0.19 改动**:新增可选 `copyx` 工具,用普通 Go 调用完成浅拷贝、深拷贝和显式类型转换;深拷贝会拒绝自定义转换中残留的源对象可变引用。**移除了 `mappings` / `mappingFiles` 和映射代码生成**,已有使用者须按 [迁移步骤](COPY.md#从旧-mapping-迁移) 调整,不能直接删除仍在调用的旧文件。
普通接口的流程是:**修改 `.api` → 生成 Request/Response → 写 Logic → 测试**。有独立职责的业务类型才需要转换;可直接写 Go,也可以按需使用 [copyx](COPY.md)。公共字段组合、中间件工厂、Repository 开关继续支持,见 [生成能力指南](GENERATION.md)。
Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可切换的 native HTTP/1.1 和实验性 HTTP/2 适配器。
响应字段、业务码、登录方式、权限规则和数据库表结构由接入项目决定。文中的 `code/msg/data`、`200/9999` 都是示例配置,可以替换。
契约支持 `returns([]string/[]int64/[]bool/[]any)`、OpenAPI 公共引用、workspace 一次导出共享 Go 错误目录,以及文件显式环境引用与配置检查。详细配置与升级步骤见 [业务开发指南](DEVELOPMENT.md)。
已有通用开发能力见 [业务开发指南](DEVELOPMENT.md):数组/null、严格请求策略、声明式上传、文件显式配置引用、资源生命周期、共享契约和升级工具。MySQL 直接使用 [官方适配器](database/mysql/README.md)。
后台组件可在 svc 登记 [分阶段关闭](lifecycle/README.md#后台组件分阶段关闭),先停止接收工作并等待收尾,再关闭共享资源;无需绑定队列或 ORM。
使用 GORM 的项目可以选择 [gormx 适配器](database/gormx/README.md),复用数据库初始化和连接池,并将 SQL 日志接入 logx。
## 当前功能与使用入口
下表是当前公开能力的索引。每项给出调用入口和完整说明;模块按需使用,`omni.New()` 不会自动初始化数据库、Redis、ORM、队列或云存储。示例中的 import 统一以 `git.yuntuo.hk/k2safe/go-omni/` 开头。
| 功能与用途 | 怎么用 | 解释与示例 |
| --- | --- | --- |
| 项目、接口与文档生成 | `go-omni new`;改 `.api` 后 `api generate`;`--check` 检查漂移 | [下方快速开始](#新项目生成并运行)、[CLI](cmd/go-omni/README.md)、[完整生成项目](examples/managedService/README.md) |
| 公共字段与跨服务共享契约 | `.api` 组合对象字段;`omni.imports.json` 声明共享根;workspace 批量生成 | [公共字段示例](GENERATION.md#公共字段组合)、[共享契约与 workspace](DEVELOPMENT.md) |
| 混合工程工作区 | `workspace generate/check` 按清单分发 API/project;format 仅处理 API | [可运行示例](examples/mixedWorkspace/README.md)、[工作区说明](DEVELOPMENT.md#混合-api-与通用工程) |
| 通用 Go 工程 | `go-omni project new/generate/check`;配置、日志、依赖、运行与退出 | [通用工程指南](PROGRAM.md)、[完整可运行示例](examples/program/README.md) |
| 中间件依赖工厂、可选 Repository | `omni.gen.json` 选择 factories / none;在 `New<Name>(deps)` 写一次实现 | [配置与完整工厂示例](GENERATION.md) |
| 路由、分组、请求上下文 | `omni.New()`、`app.GET` / `Group` / `Use`;业务使用 `context.Context` | [基本服务](examples/basic/main.go)、[已有项目接入](#已有项目按模块接入) |
| HTTP 网络与扩展适配 | 配置 transport 为 nethttp / native / http2;自定义 Router/Runner | [网络层配置与契约](transport/README.md)、[性能与限制](PERFORMANCE.md) |
| 浏览器跨域 CORS | `server.cors` / `omni.WithCORS`;独立 HTTP 用 `cors.New(...).Wrap(handler)` | [配置、默认值与协议边界](cors/README.md)、[鉴权与预检示例](examples/cors/README.md) |
| HTTP 老接口桥接 | 通过官方 Handler/Middleware 适配器接入标准 `net/http`,跟踪实际响应状态 | [接入说明](MIGRATION.md)、[桥接/上传/自定义 ID 示例](examples/compatibility/main.go) |
| 请求校验、动态 JSON、自定义标量 | `.api` 声明来源/校验/scalar;手写契约用 `contract.New[T]`;简单绑定可用 `validate.JSON/Query/URI` | [语法与例子](API.md)、[请求策略](DEVELOPMENT.md) |
| 数组、null、原始请求、上传下载 | `.api` 声明数组/可空返回、body/multipart 与大小限制;必要时使用 Bind/Manual 扩展 | [契约示例](examples/contracts)、[含上传/回调/下载的项目](examples/managedService/README.md) |
| 公共响应与特殊协议 | `.api` 定义 response 并 `@app(defaultResponse: ...)`;Go 用 `WithResponse` / Encoder | [响应配置](RESPONSE.md)、[响应模板契约](examples/contracts/responses.api) |
| 错误目录与动态提示 | `WithErrors(catalog)` 注册一次;`ByCode(...).WithParams(...).WithCause(err)` | [错误目录](errors/README.md)、[模板参数示例](examples/contracts/errorParams.api) |
| 通用国际化 | `i18n.New/Render`;可选 `WithLanguagePolicy/WithErrorLocalizer`;`go-omni i18n check/export/types/extract` | [资源格式与边界](i18n/README.md)、[CLI/HTTP/前端示例](examples/i18n/README.md)、[生成契约](examples/contracts/i18n.api) |
| OpenAPI 与错误公共引用 | `.api` 自动生成;手写接口可使用 `openapi` 注册器;Go 错误目录显式导出 | [契约工具](DEVELOPMENT.md)、[注册示例](examples/platform/main.go) |
| 结构化业务日志 | `logx.Info/Error`、`logx.FromContext(ctx)`、`logx.New`;支持独立时区、轮转、脱敏、慢请求和级别更新 | [下方日志示例](#日志)、[完整配置](logx/README.md) |
| 文件配置与环境引用 | Default()→JSON/YAML/XML 字段引用→类型绑定→校验;无隐式覆盖,支持 `-check-config` | [统一配置指南](CONFIG.md)、[完整可运行示例](examples/configuration/README.md)、[动态级别](ADVANCED.md#配置与日志热更新) |
| 生命周期与健康检查 | svc 登记 OnClose/OnStop/OnDrain,RequestShutdown 通知故障;健康路径/开关可配 | [生命周期用法](lifecycle/README.md)、[健康检查](health/README.md)、[生成项目扩展](PROJECT.md) |
| 数据库、连接池与事务 | `database.Open` / `OpenConnector`;`InTx` 的 `TxError` 提供阶段、已知结果与原因链 | [通用数据库](database/README.md)、[事务失败示例](examples/transactions/README.md)、[MySQL TLS/Logger](database/mysql/README.md) |
| 可选 GORM 与迁移 | gormx 复用已有连接池;`go-omni migrate` 预览,显式 apply | [GORM 示例](database/gormx/README.md)、[迁移用法](ADVANCED.md#迁移审计与分页) |
| 认证与权限接口 | 注入 Authenticator / Policy;JWT 或 Session 只是可选实现 | [认证说明](auth/README.md)、[鉴权完整例子](examples/platform/README.md) |
| 缓存 | `cache.NewMemory` 或 Redis 实现;业务面向 Store 的 Get/Set/Delete | [缓存配置/代码](MODULES.md)、[可运行模块服务](examples/modules/README.md) |
| 出站 HTTP、熔断 | `httpc.New` 配置代理、连接池和阶段超时;JSON 支持单次成功状态限制/请求头/限额;`resilience.New` 隔离故障 | [HTTP 完整用法](httpc/README.md)、[可运行示例](examples/httpClient/README.md)、[熔断/观测](ADVANCED.md#熔断与观测) |
| 请求追踪、访问日志、恢复、限流、幂等、超时 | `app.Use(...)` 按需组合 middleware;跨副本选择 Redis 实现 | [模块说明与限制](MODULES.md#请求追踪与基础指标)、[分布式实现](ADVANCED.md#分布式限流与幂等) |
| 本地后台任务与调度 | `task.New`、Submit、Every;有界队列、排空关闭 | [task 示例与生命周期](MODULES.md#后台执行task) |
| 持久队列 | 业务使用 Publisher / Consumer;Redis Streams 提供至少一次交付 | [队列初始化/消费示例](ADVANCED.md#可靠队列) |
| 文件存储、直传、云端分片 | Store 的 Put/Get/Delete;Local、S3/R2、OSS 按配置装配;扩展接口提供分片续传 | [存储接口与完整例子](storage/README.md)、[本地存储服务](examples/platform/README.md) |
| 日志关联、指标与链路 | `metrics.New().Middleware()`;telemetry 注入 OTel exporter/reader | [基础指标](MODULES.md#请求追踪与基础指标)、[OTel 用法](ADVANCED.md#熔断与观测)、[完整接入](examples/platform/main.go) |
| 审计与分页 | `audit.New` 注入 Sink/白名单;`pagination.Parse` / `NewResult` | [解释与调用例子](ADVANCED.md#迁移审计与分页) |
| 可选结构转换与深拷贝 | `copyx.Copy` / `Clone` / `New`;`Check[S,T]` 预检类型,Go converter 处理差异 | [下方示例](#可选结构转换与深拷贝)、[完整 copyx 指南](COPY.md)、[可运行代码](examples/copying) |
| 自定义适配器验收 | `transporttest.Run`、`storagetest.Run` / `RunUploads` 验证扩展契约 | [网络契约测试入口](transport/transporttest/router.go)、[存储契约测试入口](storage/storagetest/contract.go) |
## 通用工程快速开始
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.5.0
```
`v0.4.0` 移除了 Gin 和 Gin 专属的 `WithMode` 配置;`omni.New()` 直接使用标准库路由,已有业务 Handler / Service 无需修改。
创建新业务项目:
```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.5.0
go-omni new --module example.com/billing ./billing
cd billing
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.1-preview
go-omni project new demo --module example.com/demo
cd demo
go mod tidy
go run ./cmd/server
go run . -check-config
go run .
# Ctrl+C / SIGTERM 退出;项目自己的 Run 也可以完成后直接返回。
go-omni project check
go test ./...
```
远程安装的 CLI 默认引用自身版本,生成的项目不需要本地 `replace`。
具体版本和本地开发选项见 [CLI 文档](cmd/go-omni/README.md)。
项目只修改配置、svc 依赖初始化和 Run;默认没有 HTTP 端口、数据库、队列或自动重试。`main.go`、加载和装配代码由生成器维护;手改受管文件会报冲突,手写文件保持。完整维护边界、异常退出和超时行为见 [PROGRAM.md](PROGRAM.md)。
需要数据库持久化时,使用 `go-omni new --database mysql --module example.com/billing ./billing`。
生成项目包含完整用户 CRUD、数据库 Repository、ServiceContext、建表脚本和就绪探针。
先应用生成的 `schema.sql`,再配置 `GO_OMNI_DB_DSN`;启动时会检查连接与表结构,退出时释放连接池。
## 浏览器跨域 CORS
## 目录结构
同源部署保持默认关闭;需要跨域时,在生成项目的配置文件中加入:
```yaml
server:
cors:
enabled: true
allowedOrigins: [https://admin.example.com, https://merchant.example.com]
allowedMethods: [GET, HEAD, POST, PUT, PATCH, DELETE]
allowedHeaders: [Authorization, Content-Type]
exposedHeaders: [Content-Disposition, X-Request-ID]
allowCredentials: false
maxAge: 10m
```
不需要修改 main、Logic 或 `.api`。框架在鉴权前处理合法预检;实际请求仍鉴权,401/404/405/500 保持项目响应格式并附带允许来源的跨域头。没有 Origin 的服务端回调正常处理。配置只决定浏览器读取权限,不替代登录、验签或 CSRF 防护。
手写项目用 `omni.WithCORS(omni.CORSConfig{...})`,独立 HTTP 服务用 `cors.New(...).Wrap(handler)`;代理已经处理 CORS 时保持关闭。完整 [默认值与边界](cors/README.md)、[可运行示例](examples/cors/README.md)。
## 带认证的出站 HTTP
HTTP Client 在 svc 初始化一次,业务重复使用。普通内部请求可直接选择直连和连接参数,无需手写 Transport:
```go
client, err := httpc.New(httpc.Config{
Proxy: httpc.ProxyConfig{Mode: httpc.ProxyDirect},
Timeout: 3*time.Second, DialTimeout: time.Second,
TLSHandshakeTimeout: time.Second, ResponseHeaderTimeout: time.Second,
MaxConnsPerHost: 32, MaxIdleConnsPerHost: 16, MaxIdleConns: 64,
})
if err != nil { return err }
// 在 svc 生命周期登记 client.Close;关闭前先排空使用它的工作。
err = client.JSON(ctx, "GET", endpoint, nil, &result,
httpc.WithHeader("Authorization", "Bearer "+token),
httpc.WithMaxResponseBytes(64<<10),
)
```
请求头只作用于本次调用,不把用户 Token 留在共享 Client。默认代理/参数保持原行为;也可选择环境代理或固定代理。JSON 自动关闭并限制响应,Do 保留下载/流式读取扩展。完整配置、重试边界和已有自定义 Transport 的迁移见 [httpc](httpc/README.md)。
```sh
go run ./examples/httpClient
```
## 统一配置加载
业务只维护 Config、Default()、Validate() 和配置文件。生成器维护 Load/main;文件缺失字段保留默认值,显式 0/false/空字符串正常覆盖,固定值不会被同名环境变量再次覆盖。
```go
BatchSize int `json:"batchSize"` // 加入 Config;在 Default() 中设置 BatchSize: 100。
```
```yaml
server:
address: "${HTTP_ADDR:-127.0.0.1:8080}"
batchSize: "${BATCH_SIZE:-100}"
password: "${DB_PASSWORD}"
sessionToken: "${SESSION_TOKEN:-}"
```
`${NAME}` 未设置或为空就报错;`${NAME:-value}` 明确指定备用值。`$$` 转义 `$`;密码中的冒号、换行、引号和 `${...}` 原样插入,不二次解释。JSON/YAML/XML 共用解析后的字段处理和 Go 类型绑定,错误同时定位文件、字段、变量及可用行列,不打印原值。
```sh
go run ./examples/configuration -format xml
DEMO_CLIENT_TIMEOUT=500ms go run ./examples/configuration
# 在生成项目中,仅检查选中的文件,不初始化依赖:
go run . -config etc/config.test.yaml -check-config
```
完整 [语义与迁移](CONFIG.md)、[可运行示例](examples/configuration/README.md)。**v0.22 移除旧自动环境覆盖、前缀/别名及 env/envDefault 标签**,生成项目先迁移开发者配置再重新生成;文件中的响应模板 `${code}` 写成 `$${code}`,`.api` 保持原样。
## 选择接入方式
| 你的项目 | 从哪里开始 |
| --- | --- |
| 新建服务,先定义接口再开发 | [新项目:生成并运行](#新项目生成并运行) |
| 已有服务,逐步接入 HTTP 或基础模块 | [已有项目:按模块接入](#已有项目按模块接入) |
| 已有标准 Handler、动态 JSON、上传或自定义 ID 类型 | [迁移扩展指南](MIGRATION.md) |
| 想统一返回体、业务错误或三方回调 | [配置公共响应与独立协议](#配置公共响应与独立协议) |
| 要接日志、数据库、鉴权和存储 | [业务开发与依赖装配](#业务开发与依赖装配) |
| 要切换网络层、配置 TLS 和退出清理 | [配置与运行](#配置与运行) |
| 已接入,需要升级和检查生成代码 | [日常开发与升级](#日常开发与升级) |
## 准备环境与安装
module 地址是 `git.yuntuo.hk/k2safe/go-omni`。先确保本机 Go 和 Git 可用,并具有仓库读取权限;使用自己的 Git 凭据管理器,不把账号或 Token 写进 module 地址。
安装固定版本的 CLI,后面的命令继续使用同一终端。私有 module 加入 GOPRIVATE,保留已有配置:
```sh
export GOPRIVATE="$(go env GOPRIVATE),git.yuntuo.hk"
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.1-preview
export PATH="$(go env GOPATH)/bin:$PATH"
go-omni --help
```
如果设置过 GOBIN,将该目录加入 PATH。远程 CLI 默认引用自身版本的框架,生成的 go.mod 不需要本地 replace。开发者与 CI 使用同版 CLI;框架源码开发方式见 [CLI 文档](cmd/go-omni/README.md#版本与迁移)。
## 新项目:生成并运行
在准备存放业务项目的目录执行,`./hello` 必须是新目录或空目录;把 `example.com/hello` 换成自己的 Go module 名称。
```sh
go-omni api new --module example.com/hello ./hello
cd hello
go mod tidy
go run .
```
另开终端验证:
```sh
curl -i http://127.0.0.1:8080/api/greet/Alice
curl -i http://127.0.0.1:8080/health/ready
curl -sS http://127.0.0.1:8080/openapi.json
```
第一个接口返回 HTTP 200,业务数据为 `{"message":"Hello Alice"}`。初始模板未配置响应格式,使用默认公共体:
```json
{"code":"OK","message":"success","data":{"message":"Hello Alice"},"requestId":"本次请求编号"}
```
按 Ctrl+C 停止服务。进入项目目录即可 `go build .`;该示例不需要数据库或其他外部服务。
### 生成的项目放什么
```text
go-omni/
├── app/ # 应用启动和生命周期
├── httpx/ # 与网络框架无关的 HTTP 抽象
├── response/ # 统一响应
├── errors/ # 统一错误
├── health/ # 存活和就绪检查
├── middleware/ # 请求 ID 等基础中间件
├── logx/ # 业务层结构化日志
├── config/ # 配置管理
├── validate/ # 参数校验
├── auth/ # 认证和权限接口
├── database/ # database/sql 生命周期和事务
├── transport/nethttp/ # 标准库网络适配器
├── transport/native/ # 自研 HTTP/1.1 服务端预览
├── examples/ # 示例项目
└── cmd/ # go-omni 项目脚手架
hello/
├── api/service.api # 维护接口定义,也可 import 其他 .api
├── main.go # 生成器维护:启动、路由注册、优雅退出
├── etc/config.yaml # 项目配置
├── omni.gen.json # 项目维护的生成选项与可选映射
├── internal/
│ ├── config/ # 配置读取
│ ├── svc/ # 数据库、日志、外部客户端等依赖
│ ├── types/*Gen.go # 按 .api 文件生成 DTO
│ ├── handler/ # 生成的绑定、响应策略、路由及文档
│ ├── logic/greet/ # 在这里实现业务逻辑
│ ├── repository/ # 按需创建,默认不生成空骨架
│ └── middleware/ # 声明鉴权等中间件后生成工厂
├── docs/openapi.json # 生成的 OpenAPI 3.0.3
└── .goOmni/apiManifest.json # 生成文件所有权与校验信息,一起提交 Git
```
## 快速开始
修改 `api/service.api` 后重新生成,业务写在 `internal/logic`,数据访问写在 `internal/repository`,依赖在 `internal/svc` 装配。声明业务错误后还会生成 `internal/apierrors/errorsGen.go`。
`api new` 的问候接口可以直接运行;自定义接口未实现的 Logic 返回 **501**。新项目的具名中间件工厂未实现时直接阻止启动;v0.16 的原有 handler 模式仍返回 501。生成器不会根据 HTTP 字段猜数据库表或业务规则。
### 从自己的接口定义生成
保存下面的完整示例为 `catalog.api`,再执行后面的生成命令。这里的响应格式、错误码和提示都由这个业务项目定义。
```text
syntax = "v1"
@app (defaultResponse: CommonResponse)
response CommonResponse (
body: {"code":"${code}", "msg":"${message}", "data":"${data}"}
codeType: int32
successCode: 200
errorCode: 9999
successMessage: "操作成功"
dataMode: always
codes: {"ITEM_NOT_FOUND":404001}
)
error ItemNotFound 404 "ITEM_NOT_FOUND" "记录不存在"
type GetItemReq {
ItemID string `path:"itemId" binding:"required"`
}
type ItemResp {
ItemID string `json:"itemId"`
Name string `json:"name"`
}
@server (
group: item
prefix: /api/v1
response: CommonResponse
)
service catalog-api {
@doc "查询商品"
@errors ItemNotFound
@handler getItem
get /items/:itemId (GetItemReq) returns (ItemResp)
}
```
```sh
go-omni api validate --api catalog.api
go-omni new --api catalog.api \
--module example.com/catalog ./catalog
cd catalog
go mod tidy
```
将生成的 `internal/logic/item/getItem.go` 替换为下面的演示实现。实际业务中换成 Repository 查询,Logic 返回业务数据或错误,不再手工包装公共体:
```go
package item
import (
"context"
"example.com/catalog/internal/apierrors"
"example.com/catalog/internal/types"
)
func (s *Logic) GetItem(ctx context.Context, req *types.GetItemReq) (*types.ItemResp, error) {
if req.ItemID != "1001" {
return nil, apierrors.ItemNotFound()
}
return &types.ItemResp{ItemID: req.ItemID, Name: "示例商品"}, nil
}
```
示例 import 对应上面的 `example.com/catalog`,使用其他 module 名称时同步替换。启动后,在另一个终端验证:
```sh
# catalog 项目目录中启动;先停掉前面的 hello 服务,释放 8080 端口。
go run .
# 另一个终端:
curl -i http://127.0.0.1:8080/api/v1/items/1001
curl -i http://127.0.0.1:8080/api/v1/items/missing
```
成功时得到 `{"code":200,"msg":"操作成功","data":{"itemId":"1001","name":"示例商品"}}`;不存在时为 HTTP 404、`{"code":404001,"msg":"记录不存在","data":null}`。
需要 MySQL、文件存储或文件日志时,**首次生成**可以使用:
```sh
go-omni new --api catalog.api \
--module example.com/catalogFull --database mysql --storage local \
--log-output file --log-format json ./catalogFull
```
该命令在 `catalog.api` 所在目录执行,目标仍需是新目录。数据库迁移、表结构和 Repository 由项目维护,API 模板不自动建表;MySQL 使用结构化配置,密码通过 `GO_OMNI_DB_PASSWORD` 提供。存储还可选 `s3`、`r2`、`oss`,按生成项目的配置说明提供桶、端点和凭据。完整生成项目示例见 [managedService](examples/managedService/README.md)。
`go-omni new` 与 `go-omni api new` 统一生成契约项目。需要手写项目时,可直接引用框架包;生成器不再提供另一套 CRUD 启动目录。
## 已有项目:按模块接入
在业务项目的 **go.mod 所在目录**执行:
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.28.1-preview
```
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。
下面是可独立运行的 `main.go` 示例;迁入现有项目时,把路由调用接到自己的业务服务即可。
```go
package main
import omni "git.yuntuo.hk/k2safe/go-omni"
func main() {
app := omni.New()
app.GET("/health", func(ctx *omni.Context) error {
return ctx.OK(map[string]string{"status": "ok"})
})
app.Run(":8080")
}
```
成功响应默认使用统一结构:
```json
{
"code": "OK",
"message": "success",
"data": {"status": "ok"},
"requestId": "..."
}
```
业务错误可以直接返回:
```go
return omni.NotFound("USER_NOT_FOUND", "user was not found")
```
默认网络适配器位于 `transport/nethttp`。业务层不需要导入具体网络库;如果后续实现自己的网络层,只需要实现 `httpx.Router` / `httpx.RouterGroup`,然后通过 `omni.NewWithRouter` 启动应用:
```go
application := omni.NewWithRouter(myRouter)
```
仓库还提供了使用标准库路由的 `transport/nethttp` 参考实现。两种适配器已通过同一套路由、业务处理链和取消传播测试,完整用户 CRUD 复用同一份 Handler / Service。
自研适配器可以直接运行 `transport/transporttest.Run` 验收。具体规则、v0.4.0 的斜杠 / Any / 405 行为调整,以及自研网络内核与当前 HTTP 接口的边界,见 [适配层契约](transport/README.md)。
`app.Engine()` 返回标准 `http.Handler`,`ctx.Raw()` 返回适配器提供的 `http.ResponseWriter`,业务 API 不锁定具体网络实现。
需要试用第一版自研网络层时,业务注册代码无需改变,只替换启动器:
```go
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"log"
"net/http"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/transport/native"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/logx"
"git.yuntuo.hk/k2safe/go-omni/response"
)
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}
app := omni.New()
app.GET("/health", func(ctx *omni.Context) error {
return ctx.OK(map[string]string{"status": "ok"})
})
if err := app.RunWith(ctx, native.New(nil), ":8080"); err != nil {
log.Fatal(err)
func run() error {
closeLog, err := logx.Setup(logx.Config{
Service: "catalog", Level: "info", Format: "json", Output: "stdout",
})
if err != nil {
return err
}
defer closeLog()
app := omni.New(
omni.WithLogger(true),
omni.WithResponse(response.Config{
Body: map[string]any{
"code": "${code}", "msg": "${message}", "data": "${data}",
},
CodeType: "int32", SuccessCode: 200, ErrorCode: 9999,
SuccessMessage: "操作成功", DataMode: "always",
Codes: map[string]any{"ITEM_NOT_FOUND": 404001},
}),
)
app.GET("/api/v1/items/:itemId", func(c *omni.Context) error {
itemID := c.Param("itemId")
logx.InfoContext(c.Context(), "item.get", "itemId", itemID)
if itemID != "1001" {
return omni.NotFound("ITEM_NOT_FOUND", "记录不存在")
}
return c.OK(map[string]string{"itemId": itemID, "name": "示例商品"})
})
// 第三方协议可明确输出文本,不经过公共 JSON 包装。
app.GET("/ping", func(c *omni.Context) error {
return c.Text(http.StatusOK, "pong")
})
return app.Run("127.0.0.1:8080")
}
```
`transport/native` 当前支持 HTTP/1.1、Keep-Alive、`Content-Length` 请求体、读写超时和优雅关闭;chunked 请求体暂返回 501。它会复用连接状态和响应对象,并可通过 `MaxConnections`、`MaxConcurrentRequests` 限制内存峰值。它仍通过标准 HTTP 请求/响应接口连接业务层,适合在现有 Handler / Service 上做网络层验证。
运行 `go run .`,访问 `/api/v1/items/1001`、`/api/v1/items/missing` 和 `/ping`,可分别验证成功、业务错误及纯文本输出。`App` 实现标准 `http.Handler`,也可挂进现有 `http.Server` 或父路由;这时监听、TLS、信号和资源关闭由现有入口负责,结束时调用 `app.Close()`。
业务代码可以直接使用 `git.yuntuo.hk/k2safe/go-omni/logx` 打结构化日志,不需要依赖具体网络库:
现有 `http.HandlerFunc` 可直接通过 `app.POST("/legacy", httpx.WrapHandler(http.HandlerFunc(oldHandler)))` 接入;状态码与访问日志一致,下载和流式输出保持原协议。标准中间件、动态 JSON、上传/原文 body、手写绑定和自定义类型的完整用法见 [迁移扩展指南](MIGRATION.md)。
接入现有业务时,逐项确认:
- **协议**:保留既有 URL、HTTP 状态、字段、分页和空值语义。已有业务错误类型需要在项目内转换为 Omni 错误,或使用 `WithResponseEncoder` 适配原错误;设置字段模板不会自动识别其他库的错误。
- **鉴权**:把现有登录态和权限校验接入中间件,继续使用原来的授权范围和业务规则。原来依赖 `gin.Context` 等类型的 HTTP 代码需要适配;标准 `context.Context` 的业务服务可以继续复用。
- **数据类型**:ID、金额、日期等按项目协议转换。用 `.api` 的 `scalar` 显式关联项目自己的类型与 JSON/Text 转换方法,分别声明输入和输出协议;普通字段保持严格绑定,见 [自定义标量](MIGRATION.md#4-自定义标量项目定义行为框架连接调用)。
- **生成器**:它不会自动接管任意已有 Go 项目。希望引入 `.api` 时,先在独立新目录生成对照,再规划路由与生成目录的接入,不要用脚手架覆盖现有业务目录。
先通过一个接口的成功、参数错误、未登录、无权限及业务错误回归,再按模块迁移。框架不要求所有项目使用同一套业务目录或身份模型。
## 配置公共响应与独立协议
| 接口来源或需求 | 配置位置 |
| --- | --- |
| `.api` 生成的接口 | `response Name (...)` 定义,`@server (response: Name)` 引用 |
| 多个 `.api` 共用返回体 | 将声明放到 `common.api`,使用 `import "common.api"` |
| 某个生成接口用另一种 JSON 公共体 | 路由声明 `@response OtherResponse` |
| 只要业务 JSON、无成功包装 | 路由声明 `@response raw`;失败仍沿用组级格式 |
| 手写路由和应用级 404/405 | `omni.WithResponse(response.Config{...})`,或读取配置后传入 `WithConfig` |
| 成功和失败都要遵循特殊回调协议 | `HandleWithResponse` 绑定项目自己的 `response.Encoder` |
| 单次直接输出 JSON、文本、XML、字节或空响应 | `c.JSON` / `c.Text` / `c.XML` / `c.Bytes` / `c.NoContent` |
`${data}` 是业务返回值,`${message}` 是公开提示,`${code}` 是业务码;占位符保留原值类型,不是字符串拼接。还支持 `${requestId}`、`${ok}`、`${status}`,字段名和嵌套结构由项目决定。
- `codeType` 可以是 `string`、`int32`、`int64`;整数类型需要配置 `successCode` 和 `errorCode`。
- `codes` 把逻辑错误码映射成对外业务码;未单独映射的失败使用 `errorCode`。JSON 业务码不会改变 HTTP 状态,例如 `404001` 仍对应 HTTP 404。
- `successMessage` 设置成功提示;未设置 `errorMessage` 时沿用错误的公开提示。`messages` 可按逻辑错误码单独覆盖,优先级最高。全局 `errorMessage` 会替换未单独映射的所有失败提示,只有需要统一失败文案时才设置。
- `dataMode: always` 在无数据时输出 null;默认 `omitNil` 省略模板里相应对象字段。没有 `returns` 的 `.api` 路由默认 HTTP 204、无响应体,这与 `200 + data:null` 是不同的接口契约。
**生成接口的格式以 `.api` 为准**,修改后重新生成,运行时与 OpenAPI 一起更新;应用 YAML 不会覆盖它。404/405 没有匹配到某个 `.api` 接口,使用应用默认配置,需要统一时也配置应用默认值。
文本/XML 回调用 Go 编码器或直接输出,`.api` 可用 `@handlerMode manual` 与 `@produces` 声明成功媒体类型;特殊错误协议仍需项目维护。`@response raw` 仅表示原始 JSON。前面示例的 `c.Text` 控制这次正常返回;需要连鉴权失败、业务错误和 panic 都按回调协议响应时使用 `HandleWithResponse`。完整配置和回调示例见 [响应参考](RESPONSE.md)。
## 业务开发与依赖装配
### 日志
业务包直接使用 `logx`,不需要 HTTP 框架对象:
```go
func (s *UserService) Create(ctx *httpx.Context) error {
logx.InfoContext(ctx.Context(), "user.create", "email", input.Email)
return nil
}
// ctx 是 Service 接收的标准 context.Context。
logx.InfoContext(ctx, "order.created", "orderId", orderID)
logx.ErrorContext(ctx, "order.failed", "error", err)
```
`WithLogger(true)` 会启用 GoOmni 的统一 HTTP 访问日志;日志内容和业务日志共用 `requestId`。应用启动时可以调用 `logx.Setup` 配置 JSON/文本格式、级别、服务名和输出文件。
入口用 `logx.Setup` 初始化,退出时关闭;生成的 API 项目已经完成日志初始化和退出清理。Service、Repository 和外部请求持续传递请求的 `context.Context`,业务日志可关联 `requestId`,同时保留取消信号。框架不规定或自动注入 `userId`、`tenantId`。
应用配置可以统一从 `GO_OMNI_*` 环境变量加载,再交给 `omni.New`:
`WithLogger(true)` 开启 HTTP 访问日志。格式、级别、文件轮转、脱敏键和慢请求阈值均可配置,见 [日志](logx/README.md)。
### 鉴权与业务错误
`.api` 可声明认证方式和关联中间件,例如把下面的声明放入契约,并在相应 `@server` 块中加上 `auth: UserAuth`:
```text
auth UserAuth (
type: bearer
middleware: Auth
)
```
新项目生成 `internal/middleware/authFactory.go`,由项目实现 `NewAuth(deps)`;生成器负责初始化和注册。v0.16 或 handler 模式继续使用 `internal/middleware/auth.go`。凭据校验和身份注入由项目实现。声明会生成路由绑定与 OpenAPI 的认证描述,**不会自动实现登录或授权规则**;未实现的工厂阻止启动,原有 hook 模式返回 501。手写路由可使用 `auth.RequireAuth(authenticator)` 或自己的中间件。JWT、Session 和 Policy 按需选择。
业务错误可以集中定义,业务只选择发生了哪个错误。**HTTP 状态由项目配置,业务失败可以返回 HTTP 200**;框架不根据 HTTP 状态单独判断成功与否。
错误目录支持两种维护方式,按项目选择一种:**Go 中定义并用 `omni.WithErrors` 注册一次**,或者在公共 `.api` 中声明并生成调用函数。前者不要求 `.api` 重复声明目录或逐路由列出错误;生成路由默认继承 App 目录。
采用 `.api` 管理时,其他契约可通过 `import` 引用:
```text
errorCatalog CommonErrors (
defaultStatus: 200
errors: {
"UsernameInvalid": {"key": "USERNAME_INVALID", "code": 400002, "message": "用户名格式不正确"},
"PasswordInvalid": {"key": "PASSWORD_INVALID", "code": 400002, "message": "密码格式不正确"},
"PermissionDenied": {"key": "PERMISSION_DENIED", "status": 403, "code": 403001, "message": "没有权限"}
}
)
```
`UsernameInvalid` 是生成函数名;`key` 是唯一逻辑标识;`code` 是项目对外的字符串或整数业务码,可以重复。条目没有填写 `status` 时使用目录的 `defaultStatus`,未配置目录默认值时为 400。这个默认值只作用于该目录的条目,不会把所有框架异常都改成 200。
在已有公共响应中保留 `codeType: int32` / `int64` / `string`、成功码、未定义错误的兜底码和返回体模板;不再重复填写这几条错误的 `codes` / `messages`。通过 `@server (errors: UsernameInvalid, PasswordInvalid)` 或路由 `@errors UsernameInvalid` 引用它们。
生成后业务调用:
```go
settings, err := omni.LoadConfig("")
if err != nil {
log.Fatal(err)
}
logger, err := logx.New(settings.Log)
if err != nil {
log.Fatal(err)
}
application := omni.New(omni.WithConfig(settings))
application.OnShutdown(logger.Close)
return TokenResponse{}, apierrors.UsernameInvalid()
// 需要保留内部原因时:
return TokenResponse{}, apierrors.UsernameInvalid().WithCause(err)
```
常用变量包括 `GO_OMNI_ENABLE_LOGGER`、`GO_OMNI_LOG_LEVEL`、
`GO_OMNI_LOG_FORMAT`、`GO_OMNI_LOG_OUTPUT`、`GO_OMNI_SERVICE`、
`GO_OMNI_ENVIRONMENT`、`GO_OMNI_HTTP_ADDR` 和 `GO_OMNI_SHUTDOWN_TIMEOUT`。数据库连接仍由
`database.ConfigFromEnv` 从 `GO_OMNI_DB_*` 加载。
生成器同时提供 `internal/apierrors.Catalog()`、错误函数、路由错误目录及 OpenAPI。目录会在 App 中间件执行前绑定到生成路由;无需手动改已经生成的 main。HTTP 200 的错误使用失败响应模板;OpenAPI 用 `anyOf` 同时保留成功 DTO 和错误体,多条错误共用数字码时仍保留各自示例。完整契约见 [errors.api](examples/contracts/errors.api)。
`App` 同时实现标准 `http.Handler`。`Run` 监听 `SIGINT` / `SIGTERM`;`RunContext` 则由调用方的 context 控制退出,生成项目已使用 `signal.NotifyContext` 接好信号。
退出时先停止接收新请求,等待活跃请求完成,再执行资源清理。超过 `ShutdownTimeout` 时强制关闭连接、取消请求 context,并返回超时错误。业务代码应响应 context 取消;框架无法强制结束忽略取消的业务 goroutine。数据库连接池由 `database.Open` 创建,在服务退出时调用 `Close`。
应用资源可以直接挂到 `omni.New` 返回的生命周期上:
手写项目可在启动时加载自己的目录,保留原来的响应配置:
```go
application := omni.New()
application.OnShutdown(db.Close)
// omnierrors 是 git.yuntuo.hk/k2safe/go-omni/errors。
catalog := omnierrors.Catalog{
DefaultStatus: 200,
Definitions: map[string]omnierrors.Definition{
"USERNAME_INVALID": {Code: 400002, Message: "用户名格式不正确"},
"PASSWORD_INVALID": {Code: 400002, Message: "密码格式不正确"},
},
}
app := omni.New(
omni.WithErrors(catalog),
omni.WithResponse(response.Config{
CodeType: "int32", SuccessCode: 0, ErrorCode: 9999,
MessageField: "msg", DataMode: "always",
}),
)
// Service 只返回标准 error,不依赖 App 或 HTTP Context:
// return omnierrors.ByCode("USERNAME_INVALID").WithCause(err)
```
清理函数会按注册顺序的逆序执行,`application.Close()` 和 `RunContext` 都会触发,且重复调用安全。
目录也可放入 `config.Config.Errors`,由 JSON/YAML 加载;生成项目的路径为 `server.errors`,生成路由只有显式引用错误定义时才使用 `.api` 独立目录,未声明则继承 App。不同 App 独立持有配置,单路由也可通过 `response.WithErrors` 绑定专用目录与自定义 Encoder。直接输出文本、XML、下载或已提交的响应继续保留原协议。
服务可以把数据库、缓存等依赖注册为就绪探针:
访问日志保留实际 HTTP 状态,并增加失败标记、逻辑错误标识和目录业务码;指标、链路及框架标准中间件/幂等重放也保留失败语义。未知标识使用系统错误兜底,内部 cause 不写入默认公开响应。旧 `error (...)`、`New` / `BadRequest` / `Codes` / `Messages` 继续支持;新目录与旧映射冲突会报错,避免静默覆盖。
动态提示也可以集中配置,在目录中声明 `message: "{label}不能超过{max}个字符"` 和有序 `params`(label/string、max/int),生成带类型的调用:
```go
checker, err := health.New(health.Probe{
Name: "database",
Check: db.PingContext,
})
if err != nil {
log.Fatal(err)
}
application.GET("/health/live", checker.LivenessHandler())
application.GET("/health/ready", checker.ReadinessHandler())
return apierrors.TextTooLong("企业名称", 100)
// 最终提示:企业名称不能超过100个字符
```
存活检查只确认进程在线;就绪检查会执行全部探针,任意依赖失败时返回 HTTP 503。
运行时支持 `omnierrors.ByCode("TEXT_TOO_LONG").WithParams(...)`,与生成函数共用解析器。固定错误继续无参数调用;缺参或类型错误使用系统兜底。模板、参数类型及具体响应示例同步写入 OpenAPI。完整示例见 [errorParams.api](examples/contracts/errorParams.api)。
新项目脚手架会生成项目自己的 `internal/servicecontext`。它集中保存 Logger、数据库、缓存和外部客户端,Service 只依赖这个项目上下文,不把基础设施初始化散落到各个 Handler:
更详细的配置、作用域和升级步骤见 [统一错误目录](errors/README.md)。
### 可选结构转换与深拷贝
普通接口不需要任何拷贝配置,直接使用生成的 Request/Response。独立业务结构之间有转换需求时:
```go
services, err := servicecontext.New(ctx, logger)
if err != nil {
return err
}
defer services.Close()
userService := service.NewUsers(services)
// import "git.yuntuo.hk/k2safe/go-omni/copyx"
var input DomainInput
if err := copyx.Copy(&input, req); err != nil { return err }
// 源与目标之间的指针、map、slice 需要隔离时:
snapshot, err := copyx.Clone(source)
if err != nil { return err }
```
## 完整业务参考项目
默认浅拷贝,同名同类型字段自动赋值,保留零值;默认不会把 nil 填成非可空目标的零值。不要为使用工具新增一套重复模型。
[`examples/reference`](examples/reference/README.md) 提供按 Handler / Service / Repository / Model 分层的用户 CRUD 示例。默认使用并发安全的内存存储,支持分页、邮箱唯一校验、结构化请求日志和统一错误,无需配置数据库。
需要重复使用或自定义规则时创建 Copier:
从远程版本生成并升级一个独立业务模块,可运行:
```go
copier, err := copyx.New(
copyx.WithDeepCopy(),
copyx.WithFieldNames[Input, View](map[string]string{"Name": "DisplayName"}),
copyx.WithConverter(func(v time.Time) (string, error) {
return v.UTC().Format(time.RFC3339), nil
}),
)
if err != nil { return err }
if err := copier.Copy(&view, input); err != nil { return err }
```
自定义函数的源/目标类型由 Go 泛型确定。深拷贝时返回原指针、原 map、子切片或嵌套旧引用都会报错;字符串/整数原值可以返回。差异规则、nil 策略、字段覆盖、资源限制和原 mapping 移除步骤见 [copyx 指南](COPY.md),完整类型及可运行代码见 [examples/copying](examples/copying/main.go)。工具使用运行时检查,类型对需测试;深拷贝不适用于连接、锁等资源。
### 减少接入代码:四个常用入口
```yaml
server:
log:
timeZone: Asia/Shanghai
```
时区由 Logger 实例管理,不修改全局 `time.Local`。非法日志级别、数据库端口/连接池等配置会指出 `server.log.level`、`database.port` 等字段和原因,不打印输入值。详见 [配置诊断](CONFIG.md#框架模块的字段诊断)、[日志时区](logx/README.md#实例时区)。
```go
// 本次请求只接受 HTTP 200;默认仍接受所有 2xx。
err := client.JSON(ctx, http.MethodGet, url, nil, &result,
httpc.WithSuccessStatuses(http.StatusOK),
)
// CI/启动时检查类型,无需构造样本;仍使用 Copy 的同一组 Option。
err = copyx.Check[Request, BusinessInput](options...)
```
状态不符合要求返回 `*httpc.StatusError`,不会写入 result,响应体仍由框架关闭。Check 不执行 converter;动态 interface、具体 nil/转换失败和深拷贝引用检查有明确边界,见 [类型预检](COPY.md#类型预检)。完整可运行代码见 [developerTools](examples/developerTools/README.md)。
### 数据库、存储及其他模块
生成项目在 `internal/svc` 中创建依赖,提供给 Logic/Repository,并用 `c.OnClose(db.Close)` 登记自有资源。生成的 main 在 HTTP 排空后关闭 svc,再关闭日志。直接使用框架的手写项目则可以用 `app.OnShutdown(db.Close)`;不要对同一资源重复登记。
已有项目使用 `driver.Connector` 时,直接 `database.OpenConnector(ctx, connector, database.DefaultPoolConfig())`。MySQL 可直接用 [database/mysql](database/mysql/README.md) 配置独立 TLS、Logger、时区与拨号参数;任意驱动也可由项目构造 Connector。框架负责连接池、启动 Ping、健康检查及事务。无需转成 DSN,也无需注册全局 TLS/Logger。具体示例和资源归属见 [Connector 接入](database/README.md#connector-接入v091-preview)。
| 能力 | 包与接入说明 |
| --- | --- |
| 数据库连接池、事务、显式迁移 | `database`,见 [数据库](database/README.md);表结构与驱动由项目选择 |
| 本地文件、S3、R2、OSS、直传与临时下载 | `storage`,见 [文件存储](storage/README.md);业务依赖接口,装配时选择适配器 |
| 缓存、出站 HTTP、限流、幂等、后台任务 | `cache`、`httpc`、`middleware`、`task`,见 [基础模块](MODULES.md) |
| JWT/Session、策略、Redis 队列、审计、分页、观测 | 见 [扩展模块](ADVANCED.md);按需注入实现 |
| 存活与就绪检查 | `health`,见 [健康检查](health/README.md);数据库等依赖作为 readiness 探针 |
API 项目的数据库迁移需要单独维护并显式执行;启动不会自动改表。完整示例 [userService](examples/userService/README.md) 展示 MySQL、事务、鉴权、日志和文件导出,可参考依赖装配方式后接入自己的业务规则。
## 配置与运行
生成项目使用 **Default() → 选定文件的显式引用 → 类型绑定 → Validate()**。固定值不会被环境变量覆盖;配置规则、转义及迁移见 [配置指南](CONFIG.md)。手写入口显式加载配置,再传入 `omni.New(omni.WithConfig(settings))`。
| 文件字段 | 用途 |
| --- | --- |
| `server.address` | 监听地址,生成项目默认 127.0.0.1:8080 |
| `server.transport` | nethttp(默认)、native、http2 |
| `server.tls.certFile/keyFile` | 成对配置 TLS 证书和私钥路径 |
| `server.http.readHeaderTimeout/readTimeout` | 请求头 / 请求读取超时 |
| `server.http.writeTimeout/idleTimeout` | 响应写入 / Keep-Alive 空闲超时 |
| `server.http.maxHeaderBytes` | 请求头大小限制,字节数 |
| `server.http.defaultMaxBodyBytes` | 普通 App 路由默认请求体上限,零默认 8 MiB;路由可覆盖 |
| `server.http.maxBodyBytes` | 服务请求体硬上限,路由不得超过;零不添加低于框架 1 TiB 的硬上限 |
| `server.http.shutdownCancelTimeout` | 请求取消后等待处理函数收尾,零默认 5s;未结束则保留资源 |
| `server.shutdownTimeout` | 优雅退出等待时间,默认 10s |
| `server.enableLogger` / `server.log.level` | 访问日志开关、日志级别 |
| `server.trustedProxies` | 可信代理 IP/CIDR,文件数组 |
| `database.host/user/password/database` | 官方 MySQL 结构化连接配置 |
初始生成文件已为地址、网络层和选中模块的必要配置声明环境引用;其他字段按需要添加固定值或引用。
例如在生成项目中切换 native:
```sh
python3 scripts/verifyBusiness.py --version v0.5.0
GO_OMNI_TRANSPORT=native go run .
```
该验收会在临时目录中先固定 `v0.2.0`,再升级到 `v0.5.0`,检查无本地 replace、配置加载、结构化日志、requestId、CRUD、校验错误、业务错误和优雅退出。
手写入口可用 `omni.WithTransport(omni.TransportNative)`;TLS 配置为 `omni.WithTLS(certFile, keyFile)`。native 提供 HTTP/1.1、Keep-Alive、TLS、超时、连接/请求并发上限和对象复用;HTTP/2 是单独的实验适配器,必须配置 TLS。默认 nethttp 的 TLS 支持 HTTP/1.1 与 HTTP/2。
标准库路由和完整 GoOmni 请求链的可重复性能基线见 [PERFORMANCE.md](PERFORMANCE.md),运行 `python3 scripts/verifyPerformance.py` 可重新采样。
读写超时也可以在生成项目的 `etc/config.yaml` 中声明(放在 `server.http` 下):
在框架根目录运行:
```yaml
server:
http:
readHeaderTimeout: 5s
readTimeout: 30s
writeTimeout: 30s
idleTimeout: 60s
maxHeaderBytes: 1048576
```
代码配置使用 `omni.WithHTTPConfig(omni.HTTPConfig{ReadTimeout: 30*time.Second, WriteTimeout: 30*time.Second})`。`Run`、`RunContext`、`ServeContext` 会应用它;`NewServer` 会配置返回的标准 `http.Server`,包含可选的 Go 字段 `ErrorLog`。**网络 I/O 参数正值覆盖、零值保留所选网络层的原有默认行为、负值报错**。请求体与取消收尾字段按上表解释;网络超时含义与支持范围见 [HTTP 配置](transport/README.md#http-配置v091-preview),不要把网络 I/O 超时当成业务执行时限。
外部宿主在注册完成后可用 `app.ValidateHTTP()` 预检配置与路由限额。上传路由使用 `app.HandleWithBodyLimit("POST", "/upload", 16<<20, nil, handler)` 或 `.api @request(maxBytes: 16777216)`;限额涵盖整个请求体。配置硬上限时至少允许该值;multipart 单文件、字段数以及幂等中间件缓存限制仍独立配置。新增普通路由默认 8 MiB;原 nethttp 手写大请求请显式设置默认值或路由上限,生成路由既有 `maxBytes`(默认 1 MiB)保持。
自研网络层通过 `RunWith(ctx, runner)` 接入监听与连接生命周期,通过 `NewWithRouter(router)` 替换路由;业务 Handler/Service 不需要导入具体适配器。细节与验收契约见 [网络适配层](transport/README.md)。
生成项目的依赖可调用 `svcCtx.RequestShutdown(err)` 主动申请退出,框架排空请求、清理依赖后保留故障原因并以非零状态退出。健康检查与 OpenAPI 的开关、路径通过 `server.endpoints` 配置,健康响应默认沿用项目公共体;未就绪保持 HTTP 503。完整配置和 v0.12 项目的升级步骤见 [项目指南](PROJECT.md#依赖主动申请退出)。
`Run` 处理 SIGINT/SIGTERM;`RunContext` 接收调用方控制的退出信号。先停止接收新请求并等待排空,超时取消请求、关闭连接,再等待 `http.shutdownCancelTimeout` 收尾窗口。只有处理函数和它的 defer 全部退出才清理资源;仍有请求时返回 `lifecycle.ErrResourcesRetained`,跳过 App 清理钩子。生成入口通过 `deps.Finish(runErr)` 同步保留数据库等依赖及日志,非零退出。`App.Close`、`Engine`、`NewServer`、`RunWith` 共用这个完成屏障;业务自行启动的 goroutine、Hijack 后的工作需另行注册后台生命周期。
## 日常开发与升级
在已生成的 API 项目目录执行:
```sh
go run ./examples/reference
go-omni api validate
go-omni api inspect
go-omni api generate --dry-run
go-omni api generate
go-omni api generate --check
go test ./...
```
默认地址为 `http://127.0.0.1:8080`。示例文档提供完整的 curl 操作流程。数据只保存在内存中,重启后清空。
`main.go`、`Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Logic、Repository、中间件实现、配置和依赖装配属于项目,重复生成保留手写内容。契约变化会提示受影响的 Logic 文件和 DTO 字段,明确的签名错误会给出原签名与期望签名;生成后 `--check` 仍能检出未修复的问题。将 `.api`、`omni.gen.json`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 同时运行 `generate --check` 与 `go test ./...`。详见 [契约变更提示与检查边界](PROJECT.md#契约变更提示)。
框架的 `App` 也实现了标准 `http.Handler`,可以直接用于 `http.Server` 和 `httptest.NewServer`;业务路由可通过 `*omni.RouterGroup` 注册,无需导入网络库。
CLI 与业务 module 固定使用 `v0.28.1-preview`。v0.21 及更早项目先按 [配置迁移](CONFIG.md#v022-迁移旧模式已移除) 整理旧标签、文件与 Load,再升级依赖与 CLI 并重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。使用过旧 mapping 的项目必须先完成 [移除迁移](COPY.md#从旧-mapping-迁移),旧配置或旧生成文件会阻止生成。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
## 当前边界
## 按需深入
缓存、消息队列和定时任务暂时作为后续可选模块。认证层只定义 `Authenticator`、`Principal` 和路由保护中间件,具体 JWT、Session 或 OAuth 由项目实现。核心数据库层只依赖 `database/sql`,具体驱动、迁移和表结构由项目选择。
| 需要了解 | 文档 |
| --- | --- |
| `.api` 类型、参数绑定、错误、鉴权和生成规则 | [API 开发指南](API.md) |
| 公共响应体、提示优先级、独立回调协议 | [响应参考](RESPONSE.md) |
| CLI 完整参数、本地框架开发与迁移 | [CLI](cmd/go-omni/README.md) |
| 当前生成项目:Go 错误目录、上传、回调和下载 | [managedService](examples/managedService/README.md) |
| 数据库/事务业务参考(旧目录示例) | [userService](examples/userService/README.md) |
| 手写接口分层示例 | [reference](examples/reference/README.md) |
| 框架成熟度、历史变更 | [路线图](ROADMAP.md)、[更新记录](CHANGELOG.md) |
| 网络性能、测试条件与适用范围 | [性能说明](PERFORMANCE.md)、[HTTP/2 验收](transport/native/HTTP2.md) |
当前成熟度与后续验收标准见 [ROADMAP.md](ROADMAP.md),版本变更见 [CHANGELOG.md](CHANGELOG.md)。
当前版本可用于固定版本接入与业务验证,仍标记为 preview。`.api` 借鉴 go-zero 的接口开发方式,支持范围以本仓库文档为准,不是完整 goctl 语法兼容层;HTTP/2 保持实验状态,OSS/R2 云账号实网验收不属于本次发布范围。
## 后续开发约定
每次功能开发完成,同步更新这份功能索引、用法说明、`examples/` 和发布记录;删除或改变功能必须注明影响与迁移。代码、示例和文档一起回归后再发布。约定已记录在 [AGENTS.md](AGENTS.md),后续开发继续遵循。

526
RELEASE.md Normal file
View File

@@ -0,0 +1,526 @@
# v0.28.1-preview
workspace generate/check 支持 API 与通用工程混合分发,也可处理纯通用工程工作区。services 新增可选 kind(api/project);省略时按生成清单识别,保留无清单时显式 api 入口的原有用法。类型与清单冲突、歧义和不合法选项会报错,项目身份、路径与文件归属继续由各生成器校验。
check 只读并汇总独立工程的失败;format 只处理 API 契约,对通用工程明确显示 SKIP。generate 预检所有工程后再写入,预检失败时不修改其他工程;不改变各模块依赖或 go.work,不增加业务逻辑和第三方依赖。模板与运行时接口未变,新 workspace 功能需要更新 CLI,旧生成工程可直接纳入。
```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.1-preview
go-omni workspace generate
go-omni workspace check
```
[混合工作区示例](examples/mixedWorkspace/README.md)、[清单规则](DEVELOPMENT.md#混合-api-与通用工程)、[验收记录](reports/workspaceAcceptance.md)。56 个测试包全量 race、5 个 CLI 包的 Go 1.22 回归、实际生成的多模块构建、vet/build 均通过。
---
# v0.28.0-preview
新增通用 Go 工程生成命令 `project new/generate/check`。默认只有配置、日志、svc 依赖装配、生命周期与项目维护的 Run 入口,不生成 API、HTTP Handler、Swagger、业务任务或网络监听,也不自动连接数据库/队列或重试任务。常驻和一次性程序由普通 Go 代码决定。
入口监督 Run 的真实完成并复用现有 Runtime:信号、正常结束、运行错误或初始化失败都会进入收尾;先停止接收工作、等待已登记工作及 Run 结束,再反序释放依赖并关闭日志。Run/Init panic 转为失败;超时仍有工作则传播 ErrResourcesRetained,保留共享资源及日志,以非零状态退出。组件登记在 svc.Init 完成,业务语义和调度方式由项目负责。
配置遵守现有显式文件引用约定;支持 -check-config,在初始化日志和依赖前校验。生成器只管理 main/loadGen/runtimeGen,手写文件保留,生成文件被修改时拒绝覆盖;check/预览只读,独立 manifest/锁和共享文件事务保障生成。新模板要求框架 v0.28.0-preview;已有 new/api/workspace 行为保留,无强制迁移和新增第三方依赖。
```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.0-preview
go-omni project new demo --module example.com/demo
cd demo
go mod tidy
go run . -check-config
go run .
go-omni project check
```
完整 [通用工程指南](PROGRAM.md)、[可运行示例](examples/program/README.md)、[回归记录](reports/programAcceptance.md)。56 个测试包全量 race 与 Go 1.22 回归、生成消费者、vet/build 和真实进程退出验证通过。
空缓存远程安装已完成:通用工程 30 项检查通过(26.45 秒),原有生成项目 98 项检查、10 组消费者通过(111.07 秒),均无本地 replace。见 [通用远程报告](reports/programRemote.json)、[原有能力远程报告](reports/programManagedRemote.json)。标签固定在 `0b6c0043b6c3b58648645aeee03bf80801e6aa3f`。
---
# v0.27.0-preview
新增统一请求体策略与请求完成屏障,无业务字段、数据库依赖或新第三方包。`server.http.defaultMaxBodyBytes` 控制普通路由默认值(零为 8 MiB),`maxBodyBytes` 为不可由路由突破的服务硬上限(零仅保留 1 TiB 框架上界)。已有显式路由和生成契约限额保持,冲突会在启动前报错;旧 nethttp 手写大请求需显式设置路由或默认上限。multipart 单文件、中间件缓存及代理限额仍各自配置。
HTTP 关闭连接后继续等待处理器实际退出,收尾窗口由 `server.http.shutdownCancelTimeout` 设置(零默认 5s)。超时仍有处理器运行时返回 `lifecycle.ErrResourcesRetained`,App/Runtime/生成入口保留共享依赖和日志;不再将连接关闭当成业务完成。`Runtime.Finish(runErr)` 会传播该信号,手写入口必须处理它,避免无条件关闭额外资源。该行为适用于全部 App 服务入口;HTTP/2 低层 Shutdown 同时等待独立处理器。
详见 [请求体策略](transport/README.md#统一请求体限额)、[停机规则](lifecycle/README.md#http-请求与依赖的关闭屏障)、[运行示例](examples/httpSafety/README.md)。CLI 与框架固定使用同版 v0.27.0-preview;模板最低兼容版本仍为 v0.22,HTTP 安全改进需要升级运行时。
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.27.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.27.0-preview
go-omni api generate --check
go test ./...
```
54 个测试包全量 race、Go 1.22 兼容检查、生成项目集成测试、vet/build 及真实进程关闭检查通过,见 [验收记录](reports/httpSafetyAcceptance.md)。
空模块缓存安装远程固定版本后,10 组生成消费者和 98 项发布检查通过,耗时 107.57 秒,无本地 replace;覆盖上传限额、取消收尾、HTTP/2 断连、生成项目依赖与日志保留。标签固定在 `3b00bb55d189cbe480c7d15c8d14bcd09d3f2d52`,见 [远程验收记录](reports/httpSafetyRemote.json)。
---
# v0.26.0-preview
新增可独立使用的 i18n 底层模块及可选 HTTP/错误适配。项目维护文案和语言设置,框架提供不可变词典、语言匹配、回退、参数与分支、检查和导出;没有绑定业务身份、页面框架、数据库或响应字段名。
前后端共享版本化消息资源和固定 Unicode CLDR 46.1 基数复数规则。CLI 只读输入,支持源目录提取、缺译/参数/动态引用检查、按命名空间导出语言包和 TypeScript 类型。可选浏览器模块先加载并验证再切换,失败保留当前语言,旧请求不能覆盖新选择。
错误目录新增可选 `translation` 引用。内置响应使用展示译文,原目录文案继续参与契约校验;自定义 Encoder 通过 `PublicMessage()` 显式采用译文。业务码、HTTP 状态、成功/失败语义、cause、直接协议输出和幂等首次快照保持。旧配置与生成项目不启用时无需迁移。
完整 [使用指南](i18n/README.md)、[通用示例](examples/i18n/README.md)、[验收记录](reports/i18nAcceptance.md)。包含 translation 的新契约远程依赖最低版本为 v0.26.0-preview;未使用该元数据的模板最低版本仍为 v0.22。CLI 与框架使用同版。
同时修复 JSON 结束定界符读取失败时丢失原始错误的问题,使请求体超过外层限制时在 Go 1.22 中也保持 HTTP 413;共享目录测试工程使用 Go 1.22。
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.26.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.26.0-preview
go-omni api generate --check
go test ./...
```
发布前 54 个测试包全量 race 与 Go 1.22(含生成消费者)、vet/build、真实进程检查通过。空缓存安装远程固定版本后,10 组生成消费者和 87 项发布检查通过,耗时 83.76 秒,无本地 replace;包含下载模块的国际化、CLDR/浏览器一致性、TypeScript 类型和 CLI 工具链验证。标签固定在 `d1a747c36f15d964dd445d661623b68a45892adf`,[远程验收记录](reports/i18nRemote.json)。
---
# v0.25.0-preview
事务失败可以按阶段和已知结果处理,避免把“提交错误”误判为“数据库没有保存”。
- `database.InTx` 签名不变,普通错误返回 `*database.TxError`;Stage 为 begin/work/commit,Outcome 为 notStarted/rolledBack/unknown。成功提交仍返回 nil。
- Commit 报错一律 unknown;回调失败且显式 Rollback 返回 nil 才标记 rolledBack。BeginTx 失败不执行回调。ErrTxDone 不作为回滚成功证据,框架不重试事务。
- `Err` 与 `RollbackErr` 保留两条原因链,可通过 errors.Is/As 识别;APIError、目录参数提示和公开响应继续工作。内部原因不自动进入 HTTP 提示。
- 提供无需外部数据库的 [故障演示](examples/transactions/README.md)。真实 MySQL 与性能结果见 [验收记录](reports/transactionAcceptance.md)。不引入新依赖,不包含文件删除、补偿任务或业务规则。
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.25.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.25.0-preview
go-omni api generate --check
go test ./...
```
**兼容边界:** 既有 errors.Is/As 保持;直接 `err == sentinel`、直接类型断言或字符串匹配需调整。nil context 使用 Background,nil 回调在开启前返回 typed error。panic/Goexit 继续传播并尝试回滚,不转换为 error;标准库自动回滚不公开的原因无法还原,不能推断已回滚。结果仅覆盖按约定使用的当前事务,不覆盖非事务表、隐式提交 SQL、其他连接或外部副作用。GORM 原生 Transaction 没有被替换。
[database 使用规则](database/README.md#事务阶段与结果) 已同步。本轮 runtime 能力需要 v0.25,模板最低版本仍为 v0.22;没有新增必填配置,当前仍为 preview。
49 个测试包全量 race、Go 1.22、vet/build、取消竞争复测通过;成功事务没有新增分配。固定远程版本在空缓存下通过 77 项发布检查和 9 组生成消费者,耗时 71.93 秒。独立 MySQL TLS 容器的 53 条测试记录(含子用例)通过,包含真实提交/回滚后的确认丢失、可选 GORM、生成项目和后台关闭,耗时 28.49 秒。均无本地 replace,标签固定在 `7a177744ab2bb85f23efc0ec267fd905d349bd4b`。见 [远程发布记录](reports/transactionRemote.json)、[远程 MySQL 记录](reports/transactionMySQLRemote.json)。
---
# v0.24.0-preview(历史版本)
业务项目通过配置开启跨域,不需要手写 OPTIONS Handler 或逐条修改接口。
- 默认关闭,在 `server.cors` 显式配置来源、方法和请求头;统一文件加载、环境引用和字段级诊断自动生效。生成项目无需修改 main、svc 或 `.api`。
- 预检在鉴权前返回 204/403 空响应;实际请求继续鉴权、业务错误编码和路由处理。允许来源可读取 401/403/404/405/500,上传下载和流式能力保留。
- 只支持精确 HTTP(S) 来源,公开无凭据服务可配置单一 `*`;禁止 `*` 与凭据混用。配置初始化快照、请求并发隔离、Vary 缓存分流和有界请求头处理。
- 同一个 App 策略覆盖标准库、native 和 HTTP2;独立服务可使用 `cors.New(...).Wrap(handler)`。没有新增第三方依赖,没有移除现有功能。
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.24.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.24.0-preview
go-omni api generate
go-omni api generate --check
go run . -config etc/config.yaml -check-config
```
现有项目不配置 CORS 时行为不变。代理已处理 CORS 的项目继续关闭;跨域不替代登录、权限或 CSRF 防护。通过 App/Engine/Run 接入,直接对外暴露底层 Router 会绕过应用外层策略。本轮新增 runtime 配置需 v0.24,未使用该配置的模板最低版本仍为 v0.22。
[配置与规则](cors/README.md)、[可运行示例](examples/cors/README.md)、[验收与性能](reports/corsAcceptance.md) 已同步。49 个测试包全量 race、Go 1.22、vet/build、实际生成项目、三种 transport 的 TLS 场景和 20 项真实浏览器检查通过;30 秒 fuzz 约 288 万次。当前仍为 preview。
空缓存安装远程版本后,9 组生成消费者和 70 项发布检查通过,耗时 66.78 秒,无本地 replace;包括下载模块的 cors/app race、三种 transport 的 TLS 检查和只改配置的生成项目。标签固定在 `800974cfff6228c2697373d9961d3c7c75611d08`,见 [远程记录](reports/corsRemote.json)。
---
# v0.23.0-preview(历史版本)
减少业务接入时重复维护的配置校验、HTTP 包装和类型检查。
- 配置诊断定位 database.port、database.pool.maxIdleConns、server.log.level/timeZone 等具体字段,保留文件、环境变量、行列和安全原因;凭据不进入公开提示,原始原因可 errors.Is/As。
- JSON 可传 `httpc.WithSuccessStatuses(200)`,默认仍接受全部 2xx;不匹配时不改目标,继续负责有界读取、关闭和连接回收,不添加新的重试规则。
- `copyx.Check[S,T](options...)` 或已有 Copier.Check 复用同一组 Go 选项,无需样本和映射文件;实际拷贝不再因集合为空而漏过确定的类型不匹配。动态 interface、nil 值、converter 失败及深拷贝共享引用仍有运行时检查。
- `server.log.timeZone: Asia/Shanghai` 配置实例时区;JSON/text/子日志一致,业务 time.Time 属性保持原样。极简镜像可用 `go build -tags timetzdata .` 内置时区数据,库不强制增加体积。
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.23.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.23.0-preview
go-omni api generate
go-omni api generate --check
go run . -config etc/config.yaml -check-config
```
现有调用接口保留;配置错误文本现在更精确,业务应通过类型/字段识别错误。空集合里确定的类型错误会提前暴露,需要显式 converter 或调整实际类型,不应靠空值跳过校验。预检通过不代表 converter 对所有业务值必定成功。当前模板最低版本仍是 v0.22,本轮新增 runtime 能力需使用 v0.23。
[完整示例](examples/developerTools/README.md)、[配置](CONFIG.md)、[JSON 选项](httpc/README.md)、[类型预检](COPY.md#类型预检)、[日志时区](logx/README.md#实例时区)。验收与性能记录见 [本轮验收](reports/developerToolsAcceptance.md)。仍为 preview。
48 个测试包全量 race、Go 1.22、vet/build、实际生成消费者及约 392 万次 copyx fuzz 通过。空缓存安装远程版本后,66 项发布检查通过,包括下载模块的 config/httpc/copyx/logx race;耗时 57.06 秒,无本地 replace。标签固定在 `3bbc6f60d76cb9eae6f01fa6ca122eb1fbf481af`,见 [远程验收](reports/developerToolsRemote.json)。
---
# v0.22.0-preview(历史版本)
配置文件明确决定每个值的来源,项目只维护 Config、Default()、Validate() 和文件。
- `${NAME}` 必填,`${NAME:-fallback}` 在未设置/空字符串时使用备用值,`$$` 转义 `$`。支持字符串组合;环境值不二次展开,凭据空格和特殊字符原样保留。
- 在 JSON/YAML/XML 已解析的字段值上展开,再按目标 Go 类型绑定;缺失字段保留默认值,0/false/空字符串可显式覆盖。字段错误定位文件、环境名和可用行列,不回显值。
- 生成 Load/main 自动接入;新增 `-check-config`,选定一个文件检查,成功退出,不启动日志/依赖/监听。精简生成配置,重新生成保留业务 Config、Default、Validate 和文件。
- **不兼容变更:旧自动环境覆盖、前缀/别名、env/envDefault、database.ConfigFromEnv/ApplyEnv 已移除。** LoadFile/NewReloader 不再接收 prefix,omni.LoadConfig 参数改为文件路径。发现旧标签提示迁移。
- 配置文件中的响应模板用 `$${code}` 等转义;`.api` 中的 `${code}` 不变。日志 file/output/rotation 均由显式文件配置决定,不再隐式清除。
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.22.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.22.0-preview
# 按 CONFIG.md 迁移开发者配置后:
go-omni api generate
go-omni api generate --check
go run . -config etc/config.yaml -check-config
```
[完整规则与迁移](CONFIG.md)、[可运行示例](examples/configuration/README.md)、[场景与性能验收](reports/explicitConfigurationAcceptance.md) 已同步。48 个测试包全量 race、实际生成消费者、Go 1.22、vet/build 和约 28 万次引用 fuzz 通过;小配置固定值/带引用加载约 25 µs,属于本机启动阶段基准。当前仍为 preview。
空缓存远程 CLI 安装、下载模块 config/httpc race、8 组实际生成消费者与 `-check-config` 验收通过,无本地 replace。标签固定在 `d598b2ebee8cde36859021e882a6bc1e0bb4fd4c`;见 [远程验收记录](reports/explicitConfigurationRemote.json)。
---
# v0.21.0-preview(历史版本)
让带认证的 HTTP 调用和内部直连可以直接使用 httpc,减少手写 Transport 与 JSON 响应处理。
- Proxy 支持环境、直连、固定代理;默认保留既有策略。
- 建连、TCP keepalive、TLS 握手、响应头、100-continue、空闲超时和独立空闲连接数量可选配置;不修改全局 Transport,不关闭借用实例。
- JSON 支持 WithHeader / WithHeaders / WithMaxResponseBytes;认证信息逐请求隔离,自动关闭和有界响应处理保留。
- 成功路径减少请求复制和无用退避;output=nil 按限额流式丢弃;小错误体在大小和时间预算内尝试复用连接。
- 不新增依赖、不增加业务规则;Do 保留标准流式扩展。CLI 最新版本与模板最低版本分开,本轮模板兼容门槛仍是 v0.20。
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.21.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.21.0-preview
```
**兼容注意**:JSON 旧的直接调用仍有效,自定义 interface/函数值需要同步新增的 variadic 参数。自定义 Transport 与显式连接/代理/阶段参数混用现在报错,避免参数被忽略;迁移时在自己的 Transport 配置,或改用内置字段。没有删除原调用入口。
[参数与使用说明](httpc/README.md)、[可运行示例](examples/httpClient/README.md)、[场景与性能验收](reports/httpcAcceptance.md) 已同步到 README。48 个测试包全量 race、Go 1.22 兼容、vet/build、约 110 万次请求头 fuzz 通过。性能来自本机同条件基准,不能代替项目实际流量验证;仍为 preview。
空缓存远程安装、下载模块的 httpc race 与示例、8 组生成消费者验收通过,无本地 replace。标签固定在 `96e7d9629b5c8b78e9cfefbcb05da0ab702c242b`,见 [远程记录](reports/httpcRemote.json)。
---
# v0.20.0-preview(历史版本)
统一配置加载,减少生成项目重复维护的启动代码。
- 生成器维护 `internal/config/loadGen.go`,调用 `config.LoadProject`;项目只扩展 Config/Default/Validate,可选在 loader.go/NewLoader 注册新格式。入口、环境覆盖、日志输出规范化无需业务重复实现。
- JSON/YAML/XML 使用同一个加载与绑定流程;Decoder 通过实例注册,无全局 init 或额外强制依赖。内置格式、字段、文件大小与层数均有明确限制。
- `config.Error` 携带文件、字段、环境变量、可用行列及安全原因;`Invalid` / `AtField` 支持业务验证。保留内部错误链,不自动输出原值;大整数、空集合、null、默认值隔离和自定义反序列化有回归覆盖。
- 配置解析在日志、依赖和监听初始化之前;生成/check/dry-run 使用一致入口和文件所有权规则。
- **迁移要求:旧项目先整理手写 Load,再重新生成。** 不静默覆盖业务文件。低层配置 API 保留;新项目不再复制手写加载流程。固定 Go 数组严格检查长度,YAML alias/merge/custom tag 不再接受,避免模糊覆盖和扩展行为。
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.20.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.20.0-preview
# 先按 CONFIG.md 迁移旧 Load,再执行:
go-omni api generate
go-omni api generate --check
go test ./...
go build .
```
完整用法、格式、脱敏边界和迁移见 [统一配置指南](CONFIG.md),可运行例子见 [examples/configuration](examples/configuration/README.md)。回归、模糊测试与启动加载性能见 [验收记录](reports/configurationAcceptance.md)。47 个测试包的全量 race、Go 1.22 兼容、vet/build、30 秒 fuzz 与 5 个基准场景三轮测量通过。空缓存远程安装和 8 组生成消费者回归通过,无本地 replace;标签固定在 `c60841f901a3bcc3e9582eece6cfb2cad008fb9e`,见 [远程记录](reports/configurationRemote.json)。仍为 preview;项目的实际配置和业务依赖应自行验收。
---
# v0.19.0-preview(历史版本)
将可选结构转换改为直接 Go 调用,减少业务项目需要维护的配置。普通接口继续使用 `.api` 生成 DTO,不要求增加独立模型或任何拷贝规则。
- 新增只依赖标准库的 `copyx`:Copy、Clone、可复用 Copier;浅/深拷贝、严格字段覆盖、nil/零值策略、字段改名、忽略字段、精确类型 converter。没有全局注册和隐式数字强转。
- 深拷贝检查整个输入和输出图。自定义 converter 原样返回指针、map、slice,或新对象仍嵌套旧引用时返回错误;保留普通循环图,拒绝资源对象及超出遍历/估算分配预算的输入。
- 转换失败不提交部分目标;保留错误路径和原始错误链。并发复用只共享不可变类型元数据;源/目标读写和 converter 副作用由调用方负责。
- **不兼容变更:删除 mappings / mappingFiles、Go 类型加载与映射代码生成。** 检出旧配置/旧生成文件时给出迁移提示;不会静默删除业务正在使用的代码。公共字段组合、scalar、中间件工厂、Repository 选项保留。
- README 补齐当前能力入口、解释和用法,新增 [完整 copyx 指南](COPY.md)、[可运行示例](examples/copying);[AGENTS.md](AGENTS.md) 记录每次修改同步文档/示例/移除说明的约定。
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.19.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.19.0-preview
# 如用过旧 mapping,先按 COPY.md 完成迁移:
go-omni api generate
go-omni api generate --check
go test ./...
```
迁移步骤和语义边界见 [copyx](COPY.md#从旧-mapping-迁移)。新工具做运行时检查,不能声称具备旧生成器的编译期字段覆盖保证;实际类型对必须有测试。独立三端工资示例验证金额精度、缺失/null/零值、空集合、错误链和字段新增;不修改或代替接入项目的完整业务回归。
46 个测试包的全量 race(含实际生成项目)、vet/build、Go 1.22 兼容测试通过;30 秒 fuzz 无失败,14 种基准场景各测量三轮。验收条件、性能数据及本轮未覆盖的外部环境见 [v0.19 验收记录](reports/copyxAcceptance.md)。本版仍为 preview。 空缓存远程 CLI 与 7 组真实生成消费者验收通过,无本地 replace;标签固定在 `fd83235ce7fc15ce88a482d385d70e61f25d7b32`,详见 [远程记录](reports/copyxRemote.json)。
---
以下是历史版本记录,其中 mapping 功能已在 v0.19 移除,不应继续作为当前接入方式。
# v0.18.0-preview(历史版本)
补齐可空嵌套映射和按模块拆分配置,减少重复转换代码,业务空值规则仍由项目显式选择:
- 嵌套 mapping 支持 `*S → *T` 保留 nil、`S → *T`,以及指定 nil zero/error 后的 `*S → T`;同样适用于 slice/等长数组元素。错误保留字段路径、索引和内部错误链,失败不返回部分结果。
- `mappingFiles` 显式引用项目内的映射文件,支持跨文件向前引用;统一检查重名、循环、类型与字段覆盖。禁止多层导入、路径越界和隐式覆盖,生成期间核对全部配置输入。
- 同类型 `*T → T` 可显式配置 nil zero/error;现有 converter 保持完整控制,不默认放宽类型或混入领域规则。
- `pointerResult: true` 额外生成 `<Name>Ptr(S) (*T,error)`,原值函数保留;失败返回 nil 与原错误,辅助函数名冲突在生成前拒绝。
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.18.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.18.0-preview
go-omni api generate
go-omni api generate --check
go test ./...
```
v0.17 的单文件 mappings、现有 converter 和业务文件继续保留。配置拆分时移动完整映射条目,主配置显式列出子文件;不保留重复定义。新能力均为可选生成配置,不增加运行时依赖。nil zero 表示 Go 零值并跳过子映射;业务必填、金额精度、编号格式和快照复制仍由项目负责。
使用与迁移见 [生成能力指南](GENERATION.md),回归和发布后验收见 [验收记录](reports/nullableMappingAcceptance.md)。本版仍为 preview。
全量 44 个测试包的 race、vet、build 和实际进程验收通过;空缓存远程 CLI 与 8 组实际生成消费者回归也通过,同版依赖且无本地 replace。标签固定在 `4563a6354fac19f22cd8d407fd76d98b8cc9c406`。
---
# v0.17.0-preview(历史版本)
减少项目里的重复声明、字段赋值和中间件装配代码,业务规则继续由项目控制:
- `.api` 公共字段组合:保持 JSON 平铺,DTO、绑定、校验和 OpenAPI 同步;字段冲突、循环和超限明确报错。
- 可选结构映射:在 `omni.gen.json` 显式声明源与目标,生成编译期检查的普通 Go 赋值。不同字段名、金额、时间和指针转换由项目指定函数;遗漏字段与类型错误在写文件前失败。不使用 JSON 往返或运行时反射。
- 带依赖的中间件工厂:svc 初始化依赖后自动调用 `New<Name>(deps)` 并注册;错误、nil 或 panic 阻止启动,已登记资源按生命周期清理。
- Repository 骨架默认关闭,可按 group 启用;已有业务文件始终保留。生成选项与映射纳入重新生成和 `--check`。
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.17.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.17.0-preview
go-omni api generate
go-omni api generate --check
go test ./...
```
新项目默认使用 factories;已有项目首次升级保留 handlers 模式,需要显式迁移后再切换。映射按需开启,先准备业务类型、转换函数和依赖,再声明映射;普通引用赋值保留 Go 语义,缺失/null 语义由项目类型与转换函数定义。
使用方法、文件归属和升级步骤见 [生成能力指南](GENERATION.md),回归与发布验收见 [验收记录](reports/generationExperienceAcceptance.md)。本版仍为 preview;框架验收不等于业务项目完成迁移。
全量 44 个测试包的 race、vet、build 和实际进程验收通过;发布后的空缓存远程 CLI 与 7 组生成消费者回归也通过,同版依赖且无本地 replace。标签固定在 `8b588a24cd653dac77dd68f6777e0db367981f19`。
---
# v0.16.0-preview(历史版本)
补齐通用契约与生成体验,业务响应协议继续由项目定义:
- `returns([]string/[]int64/[]bool/[]any)` 及其他支持的基础类型数组,贯通 Logic、Handler、OpenAPI 和生成检查。nil 根数组输出 `[]`;DTO 数组、nullable、null、raw 和 204 保持原语义。字节数组明确拒绝 base64/数组歧义。
- OpenAPI 以标准 components / `$ref` 共享重复响应、schema、示例;保留同一 HTTP 200 下的成功与失败、模板参数、数字业务码和 data:null。
- workspace 显式配置 Go Catalog 提供方,一次导出共享 JSON,多服务通过受控根引用。支持服务/路由的文档错误列表,运行期 App 目录继承不变。check 核对导出漂移并保持项目文件不变。
- EnvOptions.Aliases + ServerEnvAliases 统一完整配置与框架短名称的 Lookup、类型转换、空值规则。完整名称优先,显式空值不等于未设置;旧 ApplyEnv 的空值/逗号列表行为保留。
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.16.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.16.0-preview
go-omni api generate
go-omni api generate --check
go test ./...
```
旧项目 config/Logic/svc 继续由开发者维护,不会被重新生成覆盖。已有环境配置需按 本版当时的统一覆盖方式合并 OverlayEnv 与 ApplyEnv(该模式已在 v0.22 移除,当前升级见 [配置迁移](CONFIG.md#v022-迁移旧模式已移除));共享错误目录接入见 [workspace 配置](DEVELOPMENT.md#同一份-go-错误目录供多个服务生成)。已有手写权限 Handler 切换自动生成前,先迁出实现并显式移除 Manual 文件,生成器不会静默删除。
全量 race(含实际生成消费者)、vet、build、根入口/退出/再生成验证通过。eTax 只读契约验收:API 文档 20,751,149 → 847,171 字节,ID 文档 1,008,210 → 392,481 字节;70 个操作展开引用后与原文档完全一致。空缓存远程 CLI / 固定版本消费者验收也已通过,没有本地 replace;标签固定在 `64e3bd6cfced225476d58e95037249cd0a4fe695`。详见 [验收记录](reports/contractFollowupAcceptance.md)。这不代表已完成业务项目迁移。
---
# v0.15.0-preview(历史版本)
增加通用后台分阶段关闭:生成入口先完成 HTTP 排空,再运行全部 OnStop、全部 OnDrain,确认组件收尾后逆序释放 OnClose 资源,最后关闭日志。适用于任意后台组件;不引入 Orbit,不改变业务事务或任务一致性策略。
- svc 登记成对、同名的 OnStop/OnDrain,Context 用于早期退出通知,WorkContext 供已接受工作在收尾期间使用。同一阶段并发执行,存在内部先后依赖的组件组合登记。
- server.lifecycle 配置 stopTimeout、drainTimeout、cancelTimeout。阶段超时取消工作并给未返回钩子收尾窗口;错误与阶段/组件名称可通过错误链追踪。
- 不能确认后台已经结束时,返回 ErrResourcesRetained,保留共享资源和借用日志,生成进程非零退出。未返回的 Stop 不会与自己的 Drain 重叠;错误、panic 不阻止其他组件尝试收尾。
- task.Runner 增加 StopAccepting/Wait,原 Shutdown/Close 继续可用。核心 lifecycle 不依赖 task 或其他适配器。
- 初始化和监听失败复用同一流程;重复关闭只执行一次。生成器继续保留配置、svc 和业务代码。
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.15.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.15.0-preview
go-omni api generate
go-omni api generate --check
go test ./...
```
v0.14.x 无需重写 svc/config;生成器更新 main/runtimeGen,未配置时使用默认时限。也可 api upgrade --dry-run/--apply 更新依赖与生成文件,保留已有项目扩展。需要阶段保证的旧后台 OnClose 登记,应显式迁到 Stop/Drain;v0.13 及更早版本仍按原迁移指南处理。
全量 race(含实际生成项目)、vet、build 和隔离 MySQL/GORM 验收已通过。发布后的空缓存远程生成项目与真实 MySQL/GORM 验收也通过,CLI/框架同版且无本地 replace;标签固定在 `16deead70e860052c5b629511207578c03f9b2cb`。详见 [生命周期接入](lifecycle/README.md#后台组件分阶段关闭) 与 [验收记录](reports/phasedLifecycleAcceptance.md)。Go 无法强制结束任意 goroutine;OnClose 仍须能返回,HTTP 处理器仍须响应取消。此版不会将未知的第三方组件自动改造成优雅关闭组件。
---
# v0.14.1-preview(历史版本)
增加可选 `database/gormx`:GORM 复用 go-omni 已初始化的标准池、TLS、限额、健康检查和关闭流程,业务继续使用原生 GORM 模型与事务。核心 App/数据库包的编译依赖链不引入 GORM。
在 svc.Init 中调用 `gormx.MySQL(ctx, c.DB, gormx.WithLogger(c.Logger))`;通用 Open 工厂保留其他方言入口。适配器保护借用池不被初始化失败路径关闭,并为初始化查询设置时限。查询使用 WithContext,SQL 日志接入 logx,请求标识、慢查询和错误分类贯通;默认不输出绑定参数与驱动完整错误文本。
v0.14.0 项目更新 module 与 CLI 即可,不改生成目录或 main。模板最低运行时保持 v0.14.0;修正新版 CLI 把最低兼容版本误当自身版本、导致同版消费者出现版本提示的问题。其他数据库方言和管理额外池的插件未纳入本次实库验收,原生 PrepareStmt 的生命周期限制见 [接入说明](database/gormx/README.md)。
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.14.1-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.14.1-preview
```
全量 race(含生成消费者)、vet、build,以及隔离 MySQL 的事务/取消/日志/生成项目接入通过。发布后的空缓存远程 MySQL/GORM 和生成项目验收均通过,无本地 replace,CLI 与框架使用同版。标签固定在 `b8ab6b35219d53d6de392c920fe7ecbe9a76dade`,详见 [GORM 验收](reports/gormAcceptance.md)。
---
# v0.14.0-preview(历史版本)
本版补齐契约驱动的业务开发与通用基础设施。项目继续维护接口、配置、依赖装配、Logic、SQL 和业务规则;生成器维护入口、请求绑定、路由、响应与文档。
## 安装与升级
```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.14.0-preview
go-omni new --module example.com/billing ./billing
```
新生成器与运行时一起升级。已有 v0.13 项目先运行 `go-omni api upgrade --dry-run`,再按 [定点迁移步骤](DEVELOPMENT.md#v013-项目升级) 处理;不会静默覆盖自定义 svc/config。运行期的新构造器由生成器维护,项目初始化迁入 `svc.Init`,使用 `OnClose` 登记资源。
## 本版能力
- `.api` 支持根数组、可空对象、成功 null;真正 204 独立保留。生成 Logic、Handler、OpenAPI 和漂移检查使用相同语义。
- 请求契约控制未知 query、原始 UTF-8、charset、必须/可选/禁止 body 和严格空对象。路由覆盖 App 策略,普通 JSON 只解析一次。
- multipart 声明文件、文本和 JSON 表单字段。Logic 收到校验后的 DTO;文件有界暂存、流式读取、请求结束清理,保留底层 raw/multipart/下载扩展。
- 完整项目配置支持环境覆盖、显式零值、空值策略、必填、嵌套及复杂类型。独立校验先于日志和连接创建。
- `lifecycle.Runtime` 统一逆序清理、幂等关闭、失败回滚及故障保留;App Logger 独立,支持标准 `log.Logger` 与可信请求元数据。提供依赖中间件装配示例和可选 context 错误映射。
- 官方 `database/mysql` 适配器提供结构化配置、每连接池独立 TLS/mTLS、驱动日志、启动检查与失败清理。继续返回同一 `database.DB`,不自动迁移表或重试业务事务。
- 字段注释、示例和枚举说明进入 DTO/OpenAPI;`api format` 幂等格式化。Go 错误目录可显式导出供文档使用,无需复制错误定义。
- 显式共享契约 roots、来源摘要、分文件 DTO、多服务 workspace 命令、基础模板升级预览;保留目录边界及业务文件所有权。
详见 [业务开发指南](DEVELOPMENT.md)、[数据库接入](database/mysql/README.md) 和 [验收记录](reports/businessDevelopmentAcceptance.md)。
全量 race(含生成项目)、vet、build 与本地进程验收通过。发布后的空缓存远程消费者和隔离 MySQL 验收也均通过;CLI/框架同版且无本地 replace。标签固定在 `f5478cdc7662704b164637649305361e52f7c598`,报告单独提交。
仍需业务维护:契约、配置、svc 依赖创建/探针/清理登记、错误目录、Logic、Repository 和权限中间件。特殊协议可使用保留的扩展点。声明式上传的暂存磁盘和并发量需配置容量;共享契约版本标签由项目管理,框架校验摘要但不下载依赖。此版仍为 preview,不代表 eTax/ePay 已完成业务迁移。
---
# v0.13.0-preview(历史版本)
本版补齐项目依赖主动退出与内置接口配置。ID 租约、数据库或后台任务等故障由项目判断,框架通过统一生命周期完成退出;不内置业务规则。
## 使用与升级
```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.13.0-preview
# 在业务 module 中执行
go get git.yuntuo.hk/k2safe/go-omni@v0.13.0-preview
go-omni api generate
```
新项目自动获得全部扩展。v0.12 的 config/svc 属于项目文件,重新生成会保留它们;需按 [升级步骤](PROJECT.md#从-v012-生成项目升级) 给 svc 补入 Lifecycle、RequestShutdown 与清理衔接,main 和 Handler 由生成器更新。旧 v0.11 目录仍按原迁移规则在新目录生成。
## 行为
- `svc.RequestShutdown(err)` 非阻塞通知:停止接收请求、排空、关闭依赖和日志。首个非空故障保留,故障退出码为 1;正常停止在没有其他故障时退出码为 0。初始化期间通知、并发通知和排空期间报告故障均可处理。
- App 生命周期入口保留异常 context 取消原因;符合 context.Canceled 的信号取消保持正常停止。生成入口还读取 Controller.Err,保留显式依赖故障,即使该故障包装了 context.Canceled。
- `/health/ready` 修复绕过响应配置的问题,默认与 liveness 一起使用项目 App 的响应格式。就绪成功使用项目成功码与提示;未就绪使用 `NOT_READY` 的项目错误定义,同时固定 HTTP 503。raw 模式只返回健康数据;内部探针原因仅供日志。
- `server.endpoints` 控制三个内置 GET 接口的开关和路径,健康端点另有 responseMode。支持环境变量覆盖。OpenAPI 保持原始规范,不包业务公共体;关闭 HTTP 端点不影响生成文档。
- `.api` 不再固定保留三个默认路径;启用的内置路径与业务 GET/参数/通配路由重叠时,启动前返回明确错误并清理已创建的依赖。关闭或改名后允许项目接管旧路径。全局中间件继续执行。
## 验收
源码测试和实际生成项目验证记录见 [生命周期与端点验收](performance/lifecycleEndpointsAcceptance.md)。复验命令:
```sh
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./...
go vet ./...
go build ./...
python3 scripts/verifyManagedProject.py --report performance/lifecycleEndpointsLocal.json
python3 scripts/verifyManagedRelease.py --version v0.13.0-preview --report performance/lifecycleEndpointsRemote.json
```
发布后的远程空缓存验收已通过,耗时 26.55 秒;[机器报告](performance/lifecycleEndpointsRemote.json) 确认 CLI 和生成项目使用同版远程 module,无本地 replace。报告单独提交,发布标签保持在代码提交 `f0991d3ff248e4cb32812c368f0f8ba28740a6c7`。此版仍为 preview;具体业务的租约失效保护、鉴权与事务回归由接入项目验证。资源清理顺序不代表能强行结束忽略 context 取消的业务 goroutine;项目 Close 必须停止并等待自己启动的后台任务。
---
# v0.12.0-preview(历史版本)
本版用于固定版本开展业务接入。生成器统一维护服务根目录 main.go、路由、HTTP Handler、DTO 和 OpenAPI,业务方维护 `.api`、config、svc、logic、repository 与项目中间件。目录不再生成 cmd/server、settings、servicecontext 或 service。
## 安装与接入
仓库权限及 PATH 配置见 [README](README.md#准备环境与安装)。CLI 与框架使用同一版本:
```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.12.0-preview
go-omni new --module example.com/hello ./hello
cd hello
go mod tidy
go run .
```
已存在的手写项目可直接 `go get git.yuntuo.hk/k2safe/go-omni@v0.12.0-preview`。采用生成目录的旧项目在新目录生成后迁入业务代码,详见 [接入步骤](PROJECT.md#已有服务接入步骤) 和 [文件维护边界](PROJECT.md#哪些文件由用户维护)。新 manifest v2 不兼容旧 manifest;不会静默覆盖手写 main,也不会删除独立 cmd/migrate 等工具。
## 本版行为
- 根目录支持 go run / go build;入口负责配置、日志、依赖、HTTP 启动与信号退出。svc 的 AppOptions、GlobalMiddleware 和 OnClose 是显式扩展入口;初始化/监听失败会释放资源,正常退出先排空请求再关闭依赖和日志。
- `.api` 入口通过 `@app(defaultResponse: Name)` 选择默认响应,覆盖普通路由、404/405 与相应中间件错误;多模板不会按声明顺序猜默认值。字段、状态、业务码和文案由项目配置。
- 保持 v0.11.1 的错误目录继承:Go 中注册一次,无独立错误目录的生成路由继承 App。带参数提示、内部错误链、raw 响应和独立目录保持各自行为。
- raw/multipart 上传及 `@produces` 下载可直接编写 Logic,由生成 Handler 完成受限读取、响应和流关闭。损坏 multipart 默认 400,超限 413;项目可以定制公开错误。
- 契约变更提示受影响的 Logic、DTO 字段和请求模式;明确的签名/语法/重复方法错误使 `generate --check` 失败。重新生成保留业务文件,main 和其他生成文件的漂移也会被检查。
## 验收与范围
发布前全量 race 测试、go vet、go build 和本地实际生成项目进程验收通过。发布后的空缓存远程验收也已通过,记录见 [生成项目验收](performance/managedProjectAcceptance.md) 和 [远程产物报告](performance/managedProjectRemote.json)。报告作为单独提交保存,v0.12.0-preview 标签保持在发布代码提交 `158b8002b355f09237cd12f49d9b66ee78d493f3`,没有改写。复验命令:
```sh
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./...
go vet ./...
go build ./...
python3 scripts/verifyManagedProject.py
python3 scripts/verifyManagedRelease.py --version v0.12.0-preview
```
远程验收使用空 module 缓存安装已发布 CLI,生成的业务项目仅依赖同版远程 module,无本地 replace;检查根目录运行/构建、重新生成、错误继承/模板参数、上传下载、nethttp/native 的真实请求与退出信号,以及初始化失败/监听失败/请求排空后的清理。
本版不代表具体业务项目已完成迁移验收。HTTP/2 仍为实验状态;真实数据库、云账号和业务服务器不属于本轮验收范围。轻量 Logic 检查不能代替 Go 编译器或业务测试。静态 OpenAPI 不执行 Go 代码,因此不会自动列举仅在 Go 中维护的错误目录。请求流校验不能回滚业务提前执行的副作用,关键写入应放在 `body.Complete()` 成功之后。
---
# v0.11.1-preview(历史版本)
修复生成路由使用空错误目录遮蔽 `omni.WithErrors(catalog)` 的问题。错误现在可以只在 Go 中注册一次,`.api` 无需重复定义错误目录或逐条列出错误。
## 行为
- 未声明路由错误列表:继承 App 目录,包括带参数提示及项目自定义 INTERNAL_ERROR 兜底。
- 显式引用路由错误:继续使用路由独立目录,不与 App 合并。
- `@errors none` 清空 `.api` 分组错误列表,随后继承 App。
- 错误继承不覆盖 `.api` 的返回格式、RawJSON、手写绑定或请求体限额;全局中间件提前返回、标准桥接与克隆保持一致。
- 空 `response.WithErrors` / `MustWithErrors` 返回原 Encoder,既不覆盖 Context 的目录,也不在编码阶段按空目录重新解析错误。
## 升级
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.11.1-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.11.1-preview
go-omni api generate --dry-run
go-omni api generate
go test ./...
```
运行时修复也适用于旧 v0.11.0 生成代码,因此升级运行时后即可解除空目录遮蔽。同步升级 CLI 重新生成时不再创建空目录包装;已生成业务文件继续受保护。代码生成最低运行时仍为 v0.11.0-preview。
接入见 [README](README.md)、[错误目录继承](errors/README.md) 和 [只在 Go 注册错误的契约示例](examples/contracts/errorInheritance.api)。静态 OpenAPI 不执行 Go 配置,保留契约响应结构,不自动列举运行时目录内容。
## 验收
复现用例在修复前:普通路由通过,生成/RawJSON 路由和提前返回中间件共 12 项失败,均变成 500/英文系统提示。修复后同一批用例通过。
```sh
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./...
go vet ./...
go build ./...
python3 scripts/verifyErrorInheritanceRelease.py --version v0.11.1-preview
python3 scripts/verifyErrorParamsRelease.py --version v0.11.1-preview --report performance/errorInheritanceParamsRemote.json
python3 scripts/verifyCompatibilityRelease.py --version v0.11.1-preview --report performance/errorInheritanceCompatibilityRemote.json
```
结果见 [目录继承验收](performance/errorInheritanceAcceptance.md)。本轮未修改 eTax/ePay 业务代码,没有使用业务服务器。

151
RESPONSE.md Normal file
View File

@@ -0,0 +1,151 @@
# 公共响应体与独立接口协议
v0.26 新增的 [国际化适配](i18n/README.md#接入-go-omni-错误与-http)保留现有响应契约。`Result.Message` 是校验使用的目录原文;`MessageOverride` 是可选展示译文,`PublicMessage()` 返回最终文案。内置 Formatter 自动使用译文;自定义 Encoder 显式选择,固定文本/XML 回执和直接输出不自动改写。
这些功能从 `v0.8.0-preview` 提供。CLI 与项目框架依赖请固定到同一版本;源码开发可显式使用 `--framework-dir`。`v0.7.0-preview.1` 不包含响应模板声明。
go-omni 的响应编码由应用或接口配置。没有配置时保留旧版默认值;业务可以定义自己的字段、嵌套结构、错误码类型、提示和空值行为。`response.Response` 仅保留为兼容 DTO,运行时与生成器不再通过它固定响应结构。
## 在 .api 中定义一次、到处引用
```go
response CommonResponse (
body: {
"code": "${code}",
"msg": "${message}",
"data": "${data}"
}
codeType: int32
successCode: 200
errorCode: 9999
successMessage: "操作成功"
dataMode: always
codes: {"USER_NOT_FOUND":404001}
messages: {"USER_NOT_FOUND":"用户不存在"}
)
@server (
group: user
response: CommonResponse
)
service user-api {
@handler getUser
get /users/:userId (GetUserReq) returns (UserResp)
@response raw
@handler rawUser
get /rawUsers/:userId (GetUserReq) returns (UserResp)
}
```
请求/业务返回类型照常在 `type` 中定义。上例 `UserResp` 是业务数据,由模板中的 `${data}` 放入公共体。请求失败时,同一模板输出映射后的业务错误。错误本身的 HTTP 状态保持不变,业务 `code` 与 HTTP 状态相互独立。
- 公共声明可以放进 `common.api`,其他契约通过 `import "common.api"` 复用。
- `@server (response: CommonResponse)` 指定整个 service 块的公共体;不同块可以引用同一个模板。
- `@response PartnerResponse` 为单接口替换模板,成功和错误一起生效。
- `@response raw` 让成功 JSON 直接返回业务对象,错误沿用组级模板;组本身为 raw 时,错误使用默认模板。
- 模板名使用 UpperCamelCase;未知模板、未知配置、错误的占位符和冲突设置在生成前报错。
**生成接口以 `.api` 为响应契约来源。** 生成器把选定模板编译进 `internal/handler/responseGen.go`,在全局鉴权、限流等应用中间件之前选定,然后再执行路由中间件和业务。OpenAPI 与运行时共用配置和映射规则。即使 `.api` 没有声明模板,生成接口也明确使用默认契约,不会被应用的 YAML 设置静默改变。
业务只需返回业务数据或业务错误,无需每个 Handler 手工组装公共体。生成器维护模板、路由及文档文件,重复生成不覆盖手写 Service、Repository、middleware 和配置。
## 自定义结构与占位符
公共体没有规定必须出现的字段,也可以把成功和失败分开:
```go
response PartnerResponse (
body: {
"accepted": "${ok}",
"payload": "${data}",
"meta": {"trace":"${requestId}","httpStatus":"${status}"},
"version": 1
}
errorBody: {
"accepted": "${ok}",
"error": {"reason":"${message}","id":"${code}"}
}
)
```
`body` 是 JSON 对象,可嵌套对象、数组和常量;`errorBody` 可选,需要同时定义 `body`。空对象 `{}` 也是有效模板。
| 整值占位符 | 输出值 |
| --- | --- |
| `${data}` | 当前接口的业务返回值,保留对象/数组/数值等原类型 |
| `${code}` | 映射后的 string / int32 / int64 业务错误码 |
| `${message}` | 公开提示,经 message 配置映射 |
| `${requestId}` | 请求标识;为空时省略所在对象字段 |
| `${ok}` | 成功为 true;有错误或 HTTP 状态 ≥400 时为 false |
| `${status}` | HTTP 状态整数 |
占位符只替换整个值,不执行表达式或字符串拼接。字面量 `${data}` 写成 `$${data}`。数据为 nil 时,`dataMode: omitNil`(默认)省略所在对象字段;`always` 输出 null。数组中的占位符保留位置。模板最多嵌套 24 层。
`codeType` 使用与业务字段一致的类型名,默认 `string`:
| 类型 | 含义 | OpenAPI |
| --- | --- | --- |
| `string` | 字符串业务码,例如 `"OK"`、`"USER_NOT_FOUND"` | string |
| `int32` | 32 位有符号整数,−2147483648 到 2147483647 | integer / int32 |
| `int64` | 64 位有符号整数,−9223372036854775808 到 9223372036854775807 | integer / int64 |
整数类型必须提供 `successCode` 和 `errorCode`,支持 0 和负数;默认码和 `codes` 映射都会校验范围,越界或小数会报错。`int64` 的配置读取和生成代码保留完整整数精度,并按 JSON 数值输出;消费端也需要支持对应的整数精度。需要文本形式时可用 `string` 并配置字符串码。
旧写法 `integer` 保留兼容,沿用 ±(2^53−1) 的数值范围;新配置优先写 `int32` 或 `int64`。
`codes` 按逻辑错误码覆盖默认码;`messages` 优先于 `successMessage` / `errorMessage`。未配置的公开提示沿用原错误消息;内部 cause 不会被默认编码器序列化。
简单的平铺响应也可使用 `codeField` / `messageField` / `dataField` / `requestIdField` 快捷配置,`"-"` 禁用字段。空名称沿用旧默认值。这些字段名快捷配置不能和 `body` 混用,以免两个定义冲突。
## 手写路由、配置文件与三方回调
手写路由的应用默认配置可以放在 JSON/YAML 的 `response` 字段,也可用 Go 选项:
```go
app := omni.New(omni.WithResponse(response.Config{
Body: map[string]any{"code":"${code}", "msg":"${message}", "data":"${data}"},
CodeType: "int32", SuccessCode: 200, ErrorCode: 9999, DataMode: "always",
}))
```
应用之间的配置独立,模板在创建时验证并复制;没有全局可变的响应格式。应用配置快照也会复制模板和映射。响应配置属于静态配置,变更后需重启。
单次显式输出可使用 `c.JSON(status, value)`、`c.Text(status, text)`、`c.XML(status, value)`、`c.Bytes(status, contentType, bytes)` 或 `c.NoContent()`,都绕过公共体。已经提交的响应不会再追加错误 JSON。
三方回调可能要求成功与失败都返回 HTTP 200,并分别输出 `success` / `fail`。通过接口编码器统一处理成功、错误和 panic:
```go
ack := response.EncoderFunc(func(r response.Result) (response.Payload, error) {
if r.Err != nil || r.Status >= 400 {
return response.Text(200, "fail"), nil
}
return response.Text(200, "success"), nil
})
app.HandleWithResponse("POST", "/hooks/payment", ack, paymentHandler)
```
编码器也可以设置 HTTP 状态、Header、XML 或任意字节。`omni.WithResponseEncoder` 设置应用默认编码器;`httpx.WithResponse` 是普通路由中间件,按中间件顺序生效。需要覆盖全局中间件提前拒绝时,应使用 `HandleWithResponse`。自定义 Registrar 可以实现 `httpx.ResponseRegistrar` 提供相同的早期挂载能力;未实现时辅助函数退化为普通路由中间件。
`@response raw` 表示原始 JSON,不代表任意文本/XML协议。从 v0.9.0-preview 开始,`.api` 可用 `@handlerMode manual` 配合 `@produces` 描述文本/文件等成功输出,详见 [迁移扩展](MIGRATION.md)。自定义回调错误协议仍使用上述 Go 入口,并由应用维护相应文档;生成器不会猜测自定义 Go 编码器的输出。如果在命名中间件里替换编码器,业务也必须同步维护文档。
框架内置路由的 404/405 使用应用默认格式;它们不属于某个 `.api` 分组。TLS 握手、畸形 HTTP、连接级过载等发生在进入应用前,仍按网络层协议处理。自定义编码器失败、panic 或返回非法状态/204 body 时,最终返回空 500,避免递归编码或泄露另一种响应体。
## 运行示例与验证
```sh
go run ./cmd/go-omni api validate --api examples/contracts/responses.api
go run ./cmd/go-omni api generate --api examples/contracts/responses.api \
--module example.com/responseDemo --framework-dir . --dir ../responseDemo
```
示例业务方法起初为 501,完成 Service 后就会套用声明的模板。参考 [完整 .api](examples/contracts/responses.api)。验收测试会在临时目录生成真实项目,写入业务方法,执行已生成路由,检查数字错误码、原始 JSON、鉴权错误、路由覆盖与重复生成保护。
## 错误目录与 HTTP 200 失败(v0.10.0-preview)
错误含义由项目的 `errors.Catalog` 或 `.api errorCatalog` 集中定义,返回字段/结构仍由本页的 response 模板控制。目录提供独立的逻辑标识、对外字符串/整数码、消息、HTTP 状态,使用 `WithErrors` 或生成路由绑定。详见 [统一错误目录](errors/README.md)。
`response.Result.IsError()` 综合明确失败标记、Err 和 HTTP 状态判断;HTTP 200 的业务错误不会使用成功码/成功提示,仍可选择 `errorBody`。新目录的公开码与提示优先于通用兜底;与同标识 Codes/Messages 冲突会拒绝,不会静默覆盖。没有目录的旧映射保持原有行为。
自定义 Encoder 可读取 `Code`(逻辑标识)、`BusinessCode`(目录对外码)、Message 与 Err,并返回项目要求的最终状态和媒体类型。不要仅通过 Status 判断成功;内部原因默认只供诊断,不应由 Encoder 直接序列化 Err。RawJSON 的成功输出保持原样,失败继续交给所选错误编码器。

View File

@@ -1,5 +1,10 @@
# GoOmni 成熟度计划
当前发布基线为 v0.19.0-preview:普通接口通过 `.api` → DTO → Logic 开发;独立类型需要转换时可选 copyx。旧 JSON mapping 生成已移除,见 [迁移说明](COPY.md)。当前能力、使用入口和限制以 [README](README.md) 与 [发布记录](RELEASE.md) 为准。下文保留阶段性建设记录。
当前进展:`v0.14.0-preview` 已完成契约驱动的业务开发能力、通用 Runtime/配置和官方 MySQL 适配器,并通过全量 race、实际生成服务及空缓存远程验收。新项目/升级以 [README](README.md) 和 [开发指南](DEVELOPMENT.md) 为准;下方为历史阶段记录,完整本轮验收见 [报告](reports/businessDevelopmentAcceptance.md)。具体 eTax/ePay 业务迁移与业务回归仍由接入项目完成。
## 1. 新项目启动与发布流程(已完成,v0.1.1)
- 修正脚手架固定版本引用,保留显式本地开发选项。
@@ -35,12 +40,71 @@
- 业务试用没有要求修改 Handler / Service;现有公共 API 足以支撑一套分层业务模块。
- 自动验收入口:`python3 scripts/verifyBusiness.py --version v0.4.0`。
## 5. 自研网络层、稳定性与性能基线(进行中,v0.5.0 预览)
## 5. 自研网络层、稳定性与性能基线(v0.6.0-preview 验收完成)
- 已加入标准库路由和完整 GoOmni 请求链 benchmark,输出分配次数和耗时,作为未来自研网络层的对照基线。
- 新增 `transport/native` 第一版自研 HTTP/1.1 服务端:自行管理 TCP listener、Keep-Alive、Content-Length 请求解析、响应写回、读写超时和优雅关闭;通过 `App.RunWith` 接入,业务 Handler / Service 保持不变。
- native 当前拒绝 chunked 请求体,仍使用标准 `http.Request` / `http.ResponseWriter` 作为桥接;解析器、连接抽象和生产压测将在后续阶段继续下沉。
- 已具备全量 race、vet、build 验收;生产压测、长连接规模和真实部署验证仍待完成。
- 自动验收入口:`python3 scripts/verifyPerformance.py`。
- 新增 `transport/native` 第一版自研 HTTP/1.1 服务端:自行管理 TCP listener、Keep-Alive、Content-Length / chunked 请求解析、响应写回、读写超时和优雅关闭;通过 `App.RunWith` 接入,业务 Handler / Service 保持不变。
- 标准库与 native 共用 TCP benchmark,固定 1 / 32 / 128 并发;相同应用、客户端、超时和响应检查,避免两个测试入口逐渐失去可比性。
- 新增独立服务进程压测 `internal/perfcheck`,记录成功吞吐、P50/P95/P99、服务端分配、采样堆峰值,以及 128 / 1,024 条空闲长连接的堆内存、goroutine 和断开后的回收情况。原始报告保存在 `performance/tcpBaseline.json`。
- native 已支持受 `MaxBodyBytes` 约束的 `Content-Length` / chunked 请求体,仍使用标准 `http.Request` / `http.ResponseWriter` 作为桥接;解析器、连接抽象和生产压测将在后续阶段继续下沉。
- native 请求体已改为流式读取,Handler 可边到边处理,服务端只在 Handler 返回后排空剩余 body,超时、畸形和超限 body 会取消请求并关闭连接。
- native 响应已支持标准 `http.Flusher`;HTTP/1.1 流式响应使用 chunked framing,普通响应继续走 `Content-Length`,HTTP/1.0 回退到关闭连接分帧。
- native 长请求已补客户端断开及流式写失败取消,监测退出后再复用 Reader;已验收普通/流式断开、慢 Handler 后连续 Keep-Alive 和管线数据不被误消费。短请求延迟启动监测,已有管线数据时取消依赖后续写失败或 Handler 返回。
- 已补充慢请求头、慢 Content-Length body、慢 chunked body、慢 TLS 握手、大请求体、HTTP/1.0 Keep-Alive、HEAD/204 和管线请求验收;Linux/arm64 容器已通过全量 race、vet、build,以及 3 秒 TLS 并发/空闲连接矩阵。原始报告见 [tlsBaselineLinux.json](performance/tlsBaselineLinux.json)。
- 已验证 `MaxConnections` 满载时快速拒绝新连接、请求完成后释放容量;后续仍需在目标部署环境观察文件描述符、内核 socket 缓冲和反向代理联动。
- 已验证 `MaxConcurrentRequests` 满载返回 503,活动请求结束后槽位释放,后续请求可以恢复处理。
- native `Stats()` 已记录连接和请求过载拒绝数;访问日志已支持可信代理网段配置,默认拒绝伪造的 `X-Forwarded-For`。
- native TLS 已明确只宣告 HTTP/1.1 ALPN;HTTP/2 暂由边缘代理终止或使用 `transport/nethttp`,已整理独立协议设计、流控和压测门槛,见 [native HTTP/2 进入条件](transport/native/HTTP2.md),完成后再接入 native。
- 已加入独立 `transport/http2` 实验 Runner,验证 h2c/TLS h2、连接上限、全局请求并发拒绝和恢复,以及 GOAWAY 排空、RST_STREAM 单流取消和服务关闭超时;它使用 `x/net/http2` 作为临时帧/流控实现,已完成下述 Nginx HTTP/2 回源联调,尚未接入 native 默认路径。
- HTTP/2 实验已补齐头部/请求体上限、128 路同连接多路复用、慢读取流控、协议错误、RST_STREAM 与 GOAWAY 统计,并提供 `scripts/verifyHTTP2.py --duration 30m` 的 Linux TLS 长测入口;长测默认不启用 race,短时并发检查使用 `--race`(默认 60 秒)。测试容器默认限制为 2 核、2 GiB 内存、256 个进程/线程,并设总时限和退出清理;受限长测和 Nginx 回源联调结果见下方。
- 新 Linux amd64 环境在上述硬上限内完成 HTTP/2 普通模式 2 分钟与 race 模式 60 秒短测,分别为 3,885,273 和 377,788 次请求,均为 0 失败;连接/请求归零、FD 回到基线。SIGTERM、SIGHUP 和总时限退出清理已实测。报告见 [HTTP/2 短测记录](performance/http2SmokeLinux.json);此前无资源上限的 race 长测因 RSS 增长而停止,不作为通过依据。
- 已完成受限的 HTTP/2 单连接 TLS 30 分钟长测:128 个 worker、58,102,476 次请求、0 失败及协议错误;包含客户端与服务端的采样 RSS 峰值约 22.6 MiB,连接/活动请求归零,FD 由 9 回到 6。120 份定时采样见 [HTTP/2 长测报告](performance/http2SoakLinux.json),测试提交和原始日志校验值保存在报告中。Nginx HTTP/2 回源的 GOAWAY/排空结果见下方。
- 已完成 8 条 TLS 连接、128 个 worker 的 30 分钟混合长测:53,079,128 次普通请求、106,971 次慢上传、5,588 次慢下载,0 失败及协议错误。采样 RSS 峰值约 116.6 MiB(包含客户端和服务端),前段与最后 5 分钟 RSS 中位数分别约 111.3/110.4 MiB;退出后活动连接/请求归零,goroutine 2、FD 6 均回到基线,两次 GC 后存活堆约 450 KiB。原始日志、120 份采样、测试提交和校验值见 [混合长测报告](performance/http2MixedSoakLinux.json)。
- 已补连接级窗口耗尽时的暂停、同连接 204/跨连接隔离、WINDOW_UPDATE 恢复和连接复用;已补零窗口写阻塞在关闭超时后的解除阻塞、请求取消与回收。Linux amd64 的 HTTP/2 全包 race、vet、build 已通过,见 [边界回归报告](performance/http2FlowControlLinux.json)。
- 新增 HTTP/2 零 stream 窗口时的慢响应隔离、WINDOW_UPDATE 恢复、RST_STREAM 取消与请求槽位回收测试;补充 TLS 半开握手占满连接上限时的及时拒绝、超时回收及正常请求恢复测试。
- 已完成 Nginx 1.30.5 → HTTP/2 的 TLS 回源联调:普通与 race 模式各 3 轮,共 18 个子场景通过;覆盖两段证书校验、32 次 POST 复用一条回源连接、不可信 CA/主机名拒绝、504 超时、客户端取消、GOAWAY 后旧 POST 完成及 20 个新 POST 转入新实例,无重复业务执行。见 [验收说明与报告](transport/http2/PROXY.md)。
- `scripts/verifyHTTP2Proxy.py` 顺序运行受限编译容器和禁用外部网络的测试容器;总时限、SIGTERM、SIGHUP 清理已在 Linux 实测,原有 7 个业务容器仍运行。当前结果不覆盖 Nginx master/worker 重载或 Cloudflare 实网部署。
- 已接入 `omni.New` 的统一 transport / TLS / shutdownTimeout 配置:默认 nethttp,可显式选择 native 或实验 HTTP/2;证书在监听前加载,HTTP/2 配置必须启用 TLS。`RunWith` 继续支持自定义网络层,业务不依赖具体适配器。
- 参考业务已增加五种真实 TCP/TLS 组合的 CRUD、认证权限、错误响应、requestId 日志与协议协商测试;三种适配器共用排空和超时退出测试。修复 native 退出时过早取消在途请求,并使关闭超时错误返回调用方。下一步仍为业务环境试用、native 多协议监听器设计与接入回归,HTTP/2 保持实验状态。
- 统一启动已通过 Linux 全仓库 race/vet/build 及关键场景 10 次重复验证;修复了重复测试发现的 native 活动连接转为空闲时错过关闭的竞态,并保证不响应取消的 Handler 不阻塞关闭超时返回。脚手架独立项目的 native SIGINT/SIGTERM 验证通过;失败复现与最终验收证据见 [统一启动验收](performance/transportStartup.md)。
- 已用 16 个并发 worker 完成 3,200 次建连/断连 race 验收;MaxConnections 满载拒绝、槽位释放和 TLS 连接上限均已覆盖。
- 新增 `scripts/verifyLinux.py`,固定在 Go 1.25 Linux 容器内运行 race、vet、build 和可选 TLS/非 TLS perfcheck;perfcheck 记录堆、goroutine、连接和 Linux 文件描述符。
- Linux/arm64 已完成 30 秒、128 并发、512 空闲连接的 TLS 与非 TLS 持续回归,报告见 `performance/linuxSoak.json` 和 `performance/linuxSoakTLS.json`,均为 0 错误且连接、goroutine、FD 回收正常。
- 目标 Linux amd64 服务器已完成 30 分钟、128 并发、512 空闲连接的 TLS 验收:net/http 约 11,865 req/s、native 约 13,098 req/s,均为 0 错误;native P95/P99 为 44.040/52.429 ms,空闲连接堆增量约 4.50 MiB,FD 由 7 增至 519 后回到 7。
- 新增 `scripts/verifyProxy.py`,在 Linux Docker 中用 Nginx 反向代理 native TLS 服务,检查真实 TLS、Keep-Alive、上游超时和 native 优雅关闭。
- 自动验收入口:`python3 scripts/verifyPerformance.py`;Linux 固定验收入口:`python3 scripts/verifyLinux.py --load --tls --duration 30m`;代理联调入口:`python3 scripts/verifyProxy.py`。
## 6. 可复用业务基础模块(第一版)
- `cache`:有界 LRU 内存缓存、TTL、数据复制、同进程合并回源;`cache/redis` 适配 go-redis 客户端,保留借用连接池的生命周期边界。
- `httpc`:复用出站连接、每目标主机连接上限、覆盖响应体的总超时、保守重试、requestId 传递、JSON 响应大小限制与安全日志字段。
- `middleware`:有容量上限的令牌桶限流;按已认证身份隔离的幂等保护,覆盖冲突、处理中记录、失败记录、容量与请求/响应大小上限;协作式请求 deadline。
- `task`:固定 worker、有界队列、任务超时、panic 隔离、间隔调度、不重叠执行、停止接收与排空/取消。
- `go-omni add`:向已有项目生成 Handler / Service / Repository / Model 和模块注册入口;目标存在时拒绝覆盖,默认内存 CRUD 可注入持久化 Repository。
- `examples/modules` 将以上运行时能力串联,使用真实 TLS 在 nethttp、native、实验 HTTP/2 下验证缓存、出站 requestId、认证、幂等、后台任务与退出。
- 使用文档:[业务基础模块](MODULES.md)。验证入口:`GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./...` 和 `python3 scripts/verifyModules.py`;Linux runner 顺序运行全量检查及独立 Redis 容器,硬限制 2 CPU / 2 GiB / 256 pids。
- 最终代码已通过 Linux amd64 全仓库 race/vet/build、独立生成项目 CRUD、真实 Redis 7.4.11 与三种网络层 TLS 组合测试;测试容器清理完成、原有 7 个业务容器仍运行。提交、源码摘要与完整输出见 [验收记录](performance/modulesAcceptance.md)。
- 当前幂等、限流、任务调度和合并回源均为单进程能力;Redis 仅提供缓存存储,不代表已实现分布式去重、任务持久化或跨副本选主。eTax 指定业务模块接入依用户要求暂缓。
## 7. 扩展业务模块(本批)
- 新增 OpenAPI、JWT/Session/Policy、OpenTelemetry、文件配置与日志级别热更新、熔断/并发隔离、Redis 共享限流/幂等、Redis Streams 持久队列、Local/S3/R2/OSS 存储;补充迁移、审计和分页。
- 业务依赖独立接口,云平台 SDK 和 Redis 留在适配器中;按本次范围决定不集成 Orbit,不实现工作流编排。
- 已完成本地模块测试及 race 检查,Linux 全仓库 race/vet/build、生成项目 CRUD、三个 Runner 的 TLS 示例和独立 Redis/MinIO/MySQL 均通过。共享状态、重复提交、失败消息、未确认消息接管、旧确认拒绝、对象签名和迁移 dirty 阻断有对应证据,见 [扩展模块验收](performance/advancedAcceptance.md)。OSS/R2 尚无云账号实网验收。
- 契约、使用示例与限制见 [扩展模块](ADVANCED.md);验证入口为 `scripts/verifyModules.py --advanced`,继续限制单个测试容器 2 CPU / 2 GiB / 256 pids。
## 8. API 驱动项目与真实业务示例(v0.7.0-preview)
- `.api` → DTO / 绑定校验 / 路由 / Handler / Service / Repository / OpenAPI。
- MySQL、日志与五种存储选择可组合生成;manifest 管理生成文件,保留业务文件。
- 独立用户示例完成事务审计、鉴权、文件导出、数据库故障恢复与真实远程依赖验收。
- 证据:[API 业务闭环](performance/apiWorkflowAcceptance.md)。
## 9. 接口开发体验(v0.7.0-preview.1)
- 错误目录与调用函数、401/403/409 文档、具名鉴权和运行时 hook 关联。
- 组级继承/路由覆盖、多行接口描述、标签和弃用标记。
- 路由总览、独立 OpenAPI 导出、生成预览与项目入口自动识别。
- 证据:[接口开发体验验收](performance/apiDeveloperExperienceAcceptance.md)。
以上阶段以验收结果判断完成。当前仍是基础框架试用阶段;MySQL、适配层契约和一次业务模块试用已完成,自研网络层已有可运行预览,稳定性与生产规模验证尚未完成。

View File

@@ -12,25 +12,35 @@ import (
"time"
"git.yuntuo.hk/k2safe/go-omni/config"
"git.yuntuo.hk/k2safe/go-omni/cors"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
"git.yuntuo.hk/k2safe/go-omni/middleware"
"git.yuntuo.hk/k2safe/go-omni/response"
netrouter "git.yuntuo.hk/k2safe/go-omni/transport/nethttp"
)
// App is the top-level GoOmni application. Its public behavior depends on
// httpx.Router, leaving the network implementation replaceable.
type App struct {
router httpx.Router
config config.Config
middlewares []httpx.Middleware
closers []func() error
closeOnce sync.Once
closeErr error
router httpx.Router
handler http.Handler
config config.Config
middlewares []httpx.Middleware
closers []func() error
closeOnce sync.Once
closeErr error
clientIPResolver *httpx.ClientIPResolver
responseEncoder response.Encoder
requests requestDrain
bodyLimits []routeBodyLimit
validateOnce sync.Once
validateErr error
}
// Runner is the small lifecycle boundary used by an HTTP server
// implementation. The default App.RunContext uses net/http; a custom runner
// implementation. App.RunContext selects a configured built-in server; a custom runner
// can own the listener, protocol parser and connection lifecycle.
type Runner interface {
Run(context.Context, http.Handler, string) error
@@ -63,28 +73,95 @@ func configure(options ...config.Option) config.Config {
}
func newApp(router httpx.Router, settings config.Config) *App {
settings.CORS = settings.CORS.Clone()
corsPolicy, corsErr := cors.New(settings.CORS)
if corsErr != nil {
panic(config.AtField("cors", corsErr))
}
settings.Response = settings.Response.Clone()
settings.Errors = settings.Errors.Clone()
encoder, responseErr := response.New(settings.Response)
if responseErr != nil {
panic(responseErr)
}
var responseEncoder response.Encoder = encoder
if settings.ResponseEncoder != nil {
responseEncoder = settings.ResponseEncoder
}
settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
settings.Log.RedactKeys = append([]string(nil), settings.Log.RedactKeys...)
responseEncoder, responseErr = response.WithErrors(settings.Errors, responseEncoder)
if responseErr != nil {
panic(responseErr)
}
resolver, err := httpx.NewClientIPResolver(settings.TrustedProxies...)
if err != nil {
panic("go-omni: " + err.Error())
}
middlewares := []httpx.Middleware{middleware.RequestID(settings.RequestIDHeader)}
if settings.EnableLogger {
middlewares = append(middlewares, middleware.AccessLog())
middlewares = append(middlewares, middleware.AccessLog(middleware.WithSlowRequestThreshold(settings.SlowRequestThreshold)))
}
middlewares = append(middlewares, middleware.Recovery())
return &App{router: router, config: settings, middlewares: middlewares}
application := &App{router: router, config: settings, middlewares: middlewares, clientIPResolver: resolver, responseEncoder: responseEncoder}
application.requests.done = make(chan struct{})
application.handler = corsPolicy.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.ContentLength > application.hardBodyLimit() {
application.adapt(func(*httpx.Context) error { return apierrors.New(413, "BODY_TOO_LARGE", "request body exceeds limit") }).ServeHTTP(w, r)
return
}
router.ServeHTTP(w, r)
}))
if fallback, ok := router.(httpx.FallbackRouter); ok {
adaptError := func(status int, code, message string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
application.adapt(func(*httpx.Context) error { return apierrors.New(status, code, message) }).ServeHTTP(w, r)
})
}
fallback.SetFallbacks(adaptError(404, "NOT_FOUND", "route not found"), adaptError(405, "METHOD_NOT_ALLOWED", "method not allowed"))
}
return application
}
// Router returns the transport contract used by this application.
// Router exposes the underlying routing contract for advanced registration.
// Serve through App or Engine for CORS, hard body limits and shutdown tracking.
func (a *App) Router() httpx.Router { return a.router }
// Engine is kept as a compatibility name for the old API. It returns a
// standard http.Handler, so callers do not depend on a router type.
func (a *App) Engine() http.Handler { return a.router }
func (a *App) Engine() http.Handler { return a }
// ServeHTTP implements http.Handler for standard servers and HTTP tests.
func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
a.router.ServeHTTP(writer, request)
ctx, work := a.requests.begin(request.Context())
if work == nil {
// Do not invoke encoders, middleware or loggers after their owners close.
http.Error(writer, "server shutting down", http.StatusServiceUnavailable)
return
}
defer a.requests.end(work)
a.validateOnce.Do(func() { a.validateErr = a.ValidateHTTP() })
if a.validateErr != nil {
http.Error(writer, "invalid HTTP configuration", http.StatusServiceUnavailable)
return
}
request = request.WithContext(ctx)
if request.Body != nil {
request.Body = http.MaxBytesReader(writer, request.Body, a.hardBodyLimit())
}
a.handler.ServeHTTP(writer, request)
}
// Config returns the effective application configuration.
func (a *App) Config() config.Config { return a.config }
func (a *App) Config() config.Config {
settings := a.config
settings.CORS = settings.CORS.Clone()
settings.Response = settings.Response.Clone()
settings.Errors = settings.Errors.Clone()
settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
settings.Log.RedactKeys = append([]string(nil), settings.Log.RedactKeys...)
return settings
}
// Use adds middleware to routes registered after this call.
func (a *App) Use(middleware ...httpx.Middleware) *App {
@@ -93,7 +170,8 @@ func (a *App) Use(middleware ...httpx.Middleware) *App {
}
// OnShutdown registers a cleanup function owned by the application. Cleanup
// functions run in reverse registration order when Close or RunContext ends.
// functions run in reverse registration order after all handlers have returned.
// Incomplete draining skips these callbacks and returns ErrResourcesRetained.
// This keeps omni.New as the only public startup entry point while allowing
// projects to attach databases, loggers and other resources to its lifecycle.
func (a *App) OnShutdown(cleanup func() error) *App {
@@ -103,9 +181,21 @@ func (a *App) OnShutdown(cleanup func() error) *App {
return a
}
// Close releases application resources. It is safe to call more than once.
// Close stops admission, cancels active requests and waits for their handlers
// to return before releasing resources. It is idempotent. If cancellation grace
// expires, ErrResourcesRetained is permanent and no cleanup callbacks run.
// Use RunContext/ServeContext for graceful draining before cancellation. Do not
// call Close from a handler or cleanup callback; it would wait for itself.
func (a *App) Close() error {
a.closeOnce.Do(func() {
timeout := a.config.HTTP.ShutdownCancelTimeout
if timeout <= 0 {
timeout = 5 * time.Second
}
if err := a.requests.cancelAndWait(timeout); err != nil {
a.closeErr = err
return
}
for index := len(a.closers) - 1; index >= 0; index-- {
if err := a.closers[index](); err != nil {
a.closeErr = errors.Join(a.closeErr, err)
@@ -123,49 +213,79 @@ func (a *App) Run(address ...string) error {
return a.RunContext(ctx, address...)
}
// NewServer creates a standard HTTP server around the application.
// NewServer creates a plain standard HTTP server with the configured HTTP options.
// Invalid HTTP options panic; RunContext/ServeContext return validation errors.
// Use RunContext or ServeContext to apply the configured transport and TLS.
func (a *App) NewServer(address string) *http.Server {
if err := a.ValidateHTTP(); err != nil {
panic(err)
}
if address == "" {
address = a.config.Address
if address == "" {
address = ":8080"
}
}
return &http.Server{Addr: address, Handler: a.router}
cfg := a.config.HTTP
return &http.Server{Addr: address, Handler: a,
ReadHeaderTimeout: cfg.ReadHeaderTimeout, ReadTimeout: cfg.ReadTimeout,
WriteTimeout: cfg.WriteTimeout, IdleTimeout: cfg.IdleTimeout,
MaxHeaderBytes: cfg.MaxHeaderBytes, ErrorLog: cfg.ErrorLog}
}
// RunContext serves until the context is canceled, then performs a graceful shutdown.
func (a *App) RunContext(ctx context.Context, address ...string) error {
func (a *App) RunContext(ctx context.Context, address ...string) (runErr error) {
defer func() { runErr = shutdownResult(ctx, runErr) }()
if ctx == nil {
ctx = context.Background()
}
if ctx.Err() != nil {
return a.Close()
}
listenOn := a.config.Address
if listenOn == "" {
listenOn = ":8080"
}
if len(address) > 0 && address[0] != "" {
listenOn = address[0]
}
server := a.NewServer(listenOn)
listener, err := net.Listen("tcp", server.Addr)
stopWatching := a.watchShutdown(ctx)
defer stopWatching()
serve, err := a.prepareTransport()
if err != nil {
return errors.Join(err, a.Close())
}
return a.serveContext(ctx, server, listener)
listener, err := net.Listen("tcp", a.listenAddress(address...))
if err != nil {
return errors.Join(err, a.Close())
}
defer listener.Close()
return errors.Join(serve(ctx, listener), a.Close())
}
// RunWith starts a custom server runner while keeping App-owned cleanup in the
// same lifecycle. The runner receives the transport-neutral App handler.
func (a *App) RunWith(ctx context.Context, runner Runner, address ...string) error {
// same lifecycle. The runner receives the transport-neutral App handler and
// owns transport/TLS settings; built-in transport selection is bypassed.
func (a *App) RunWith(ctx context.Context, runner Runner, address ...string) (runErr error) {
defer func() { runErr = shutdownResult(ctx, runErr) }()
if runner == nil {
return errors.Join(errors.New("go-omni: nil runner"), a.Close())
}
if ctx == nil {
ctx = context.Background()
}
if ctx.Err() != nil {
return a.Close()
}
if err := a.ValidateHTTP(); err != nil {
return errors.Join(err, a.Close())
}
stopWatching := a.watchShutdown(ctx)
defer stopWatching()
return errors.Join(runner.Run(ctx, a, a.listenAddress(address...)), a.Close())
}
func shutdownResult(ctx context.Context, err error) error {
if cause := lifecycle.Cause(ctx); cause != nil && !errors.Is(err, cause) {
return errors.Join(err, cause)
}
return err
}
func (a *App) listenAddress(address ...string) string {
listenOn := a.config.Address
if listenOn == "" {
listenOn = ":8080"
@@ -173,21 +293,31 @@ func (a *App) RunWith(ctx context.Context, runner Runner, address ...string) err
if len(address) > 0 && address[0] != "" {
listenOn = address[0]
}
return errors.Join(runner.Run(ctx, a, listenOn), a.Close())
return listenOn
}
func (a *App) serveContext(ctx context.Context, server *http.Server, listener net.Listener) error {
serveErr := make(chan error, 1)
go func() { serveErr <- server.Serve(listener) }()
go func() {
if server.TLSConfig != nil {
serveErr <- server.ServeTLS(listener, "", "")
} else {
serveErr <- server.Serve(listener)
}
}()
select {
case err := <-serveErr:
// Serve returning only proves the accept loop ended. Cancel any remaining
// connections before the application completion barrier and cleanup.
_ = server.Close()
closeErr := a.Close()
if err == http.ErrServerClosed {
return closeErr
}
return errors.Join(err, closeErr)
case <-ctx.Done():
a.requests.stop()
shutdownTimeout := a.config.ShutdownTimeout
if shutdownTimeout <= 0 {
shutdownTimeout = 10 * time.Second
@@ -196,8 +326,8 @@ func (a *App) serveContext(ctx context.Context, server *http.Server, listener ne
defer cancel()
shutdownErr := server.Shutdown(shutdownCtx)
if shutdownErr != nil {
// Shutdown leaves active connections open on timeout. Close them so
// request contexts are canceled before releasing shared resources.
// Closing sockets requests cancellation; App.Close separately waits
// for the actual handler return before releasing shared resources.
shutdownErr = errors.Join(shutdownErr, server.Close())
}
if err := <-serveErr; err != nil && err != http.ErrServerClosed {
@@ -211,5 +341,5 @@ func (a *App) writeError(ctx *httpx.Context, err error) {
if err == nil || ctx.Responded() {
return
}
ctx.WriteError(apierrors.From(err))
ctx.WriteError(err)
}

91
app/body.go Normal file
View File

@@ -0,0 +1,91 @@
package app
import (
"fmt"
"net/http"
"strings"
"git.yuntuo.hk/k2safe/go-omni/config"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/internal/httplimits"
"git.yuntuo.hk/k2safe/go-omni/response"
)
type routeBodyLimit struct {
method, path string
maxBytes int64
}
func (a *App) hardBodyLimit() int64 {
if a.config.HTTP.MaxBodyBytes > 0 {
return a.config.HTTP.MaxBodyBytes
}
return httplimits.MaxBodyBytes
}
func (a *App) defaultBodyLimit() int64 {
limit := a.config.HTTP.DefaultMaxBodyBytes
if limit <= 0 {
limit = httplimits.DefaultBodyBytes
}
if limit > a.hardBodyLimit() {
limit = a.hardBodyLimit()
}
// Invalid configuration is rejected at startup or the first external request.
if limit > httplimits.MaxBodyBytes {
limit = httplimits.MaxBodyBytes
}
return limit
}
// ValidateHTTP checks HTTP settings and registered route body limits without
// opening a listener. Call after registration when hosting App/Engine externally.
// Built-in serving entry points and RunWith perform this check automatically.
func (a *App) ValidateHTTP() error {
if err := a.config.HTTP.Validate(); err != nil {
return err
}
for _, route := range a.bodyLimits {
if route.maxBytes > a.hardBodyLimit() {
return &config.ConfigError{Field: "http.maxBodyBytes", Message: fmt.Sprintf("route %s %s maxBytes=%d exceeds service hard limit=%d; raise http.maxBodyBytes or lower the route limit", route.method, route.path, route.maxBytes, a.hardBodyLimit())}
}
}
return nil
}
func joinRoutePath(prefix, path string) string {
if path == "" {
if prefix == "" {
return "/"
}
return prefix
}
return strings.TrimSuffix(prefix, "/") + "/" + strings.TrimPrefix(path, "/")
}
func (a *App) bodyHandler(maxBytes int64, encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) http.Handler {
checked := httpx.LimitBody(maxBytes)(handler)
if encoder == nil {
encoder = a.responseEncoder
}
next := a.adaptResponse(encoder, checked, middleware...)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Body != nil {
r = r.WithContext(r.Context())
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
}
next.ServeHTTP(w, r)
})
}
// HandleWithBodyLimit caps the reader before application middleware and preserves
// the response policy, including auth failures and parameter errors.
func (a *App) HandleWithBodyLimit(method, path string, maxBytes int64, encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) {
a.router.Handle(method, path, a.bodyHandler(maxBytes, encoder, handler, middleware...))
a.bodyLimits = append(a.bodyLimits, routeBodyLimit{method, path, maxBytes})
}
func (g *RouterGroup) HandleWithBodyLimit(method, path string, maxBytes int64, encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) {
combined := append(append([]httpx.Middleware{}, g.middlewares...), middleware...)
g.group.Handle(method, path, g.app.bodyHandler(maxBytes, encoder, handler, combined...))
g.app.bodyLimits = append(g.app.bodyLimits, routeBodyLimit{method, joinRoutePath(g.prefix, path), maxBytes})
}

224
app/bodyPolicy_test.go Normal file
View File

@@ -0,0 +1,224 @@
package app
import (
"bytes"
"context"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"git.yuntuo.hk/k2safe/go-omni/config"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/internal/testutil"
"git.yuntuo.hk/k2safe/go-omni/response"
)
func serveBodyPolicy(t *testing.T, a *App, transport string) (string, *http.Client) {
t.Helper()
cert, key, clientTLS := testutil.CertificateFiles(t)
a.config.Transport = transport
a.config.TLS = config.TLSConfig{CertFile: cert, KeyFile: key}
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- a.ServeContext(ctx, l) }()
client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{TLSClientConfig: clientTLS, ForceAttemptHTTP2: transport == config.TransportHTTP2}}
t.Cleanup(func() {
client.CloseIdleConnections()
cancel()
select {
case err := <-done:
if err != nil {
t.Error(err)
}
case <-time.After(5 * time.Second):
t.Error("server did not stop")
}
})
return "https://" + l.Addr().String(), client
}
func consumeBody(c *httpx.Context) error {
_, err := io.Copy(io.Discard, c.Request().Body)
if err != nil {
return err
}
return c.Text(200, "accepted")
}
func TestUploadPolicyAcrossTransports(t *testing.T) {
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
t.Run(transport, func(t *testing.T) {
a := New()
a.POST("/default", consumeBody)
a.HandleWithBodyLimit("POST", "/large", 16<<20, nil, consumeBody)
a.Group("/v1").Group("files").HandleWithBodyLimit("POST", "/large", 16<<20, nil, consumeBody)
base, client := serveBodyPolicy(t, a, transport)
payload := bytes.Repeat([]byte{'x'}, 9<<20)
for _, chunked := range []bool{false, true} {
for _, path := range []string{"/default", "/large", "/v1/files/large"} {
var body io.Reader = bytes.NewReader(payload)
if chunked {
body = struct{ io.Reader }{body}
}
req, _ := http.NewRequest("POST", base+path, body)
res, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
data, err := io.ReadAll(res.Body)
res.Body.Close()
if err != nil {
t.Fatal(err)
}
want := 200
if path == "/default" {
want = 413
}
if res.StatusCode != want || (want == 413 && !strings.Contains(string(data), "BODY_TOO_LARGE")) {
t.Fatalf("%s unknownLength=%v: %d %s", path, chunked, res.StatusCode, data)
}
if transport == config.TransportHTTP2 && res.ProtoMajor != 2 {
t.Fatal("HTTP/2 not negotiated")
}
}
}
})
}
}
func TestConfiguredBodyPolicyBeforeMiddleware(t *testing.T) {
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
t.Run(transport, func(t *testing.T) {
a := New(config.WithHTTPConfig(config.HTTPConfig{DefaultMaxBodyBytes: 32, MaxBodyBytes: 128}))
var calls atomic.Int32
a.Use(func(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error {
data, err := io.ReadAll(c.Request().Body)
if err != nil {
return err
}
c.Request().Body = io.NopCloser(bytes.NewReader(data))
return next(c)
}
})
h := func(c *httpx.Context) error { calls.Add(1); return consumeBody(c) }
a.POST("/default", h)
a.HandleWithBodyLimit("POST", "/override", 64, nil, h)
a.HandleWithBodyLimit("POST", "/hard", 128, nil, h)
base, client := serveBodyPolicy(t, a, transport)
for _, tc := range []struct {
path string
size, status int
}{
{"/default", 32, 200}, {"/default", 33, 413}, {"/override", 64, 200}, {"/override", 65, 413}, {"/hard", 128, 200}, {"/hard", 129, 413},
} {
for _, unknown := range []bool{false, true} {
before := calls.Load()
var body io.Reader = strings.NewReader(strings.Repeat("a", tc.size))
if unknown {
body = struct{ io.Reader }{body}
}
res, err := client.Post(base+tc.path, "application/octet-stream", body)
if err != nil {
t.Fatal(err)
}
data, err := io.ReadAll(res.Body)
res.Body.Close()
if err != nil || res.StatusCode != tc.status {
t.Fatalf("%+v unknown=%v: %d %s %v", tc, unknown, res.StatusCode, data, err)
}
if tc.status == 413 && (calls.Load() != before || !strings.Contains(string(data), "BODY_TOO_LARGE")) {
t.Fatal("oversize reached business or lost error", string(data))
}
}
}
})
}
}
func TestBodyHardLimitConflictBeforeServing(t *testing.T) {
for _, entry := range []string{"RunContext", "ServeContext", "RunWith", "NewServer", "Engine"} {
t.Run(entry, func(t *testing.T) {
a := New(config.WithHTTPConfig(config.HTTPConfig{MaxBodyBytes: 8 << 20}))
a.Group("/v1").Group("files").HandleWithBodyLimit("POST", "/upload/", 16<<20, nil, consumeBody)
closed := 0
a.OnShutdown(func() error { closed++; return nil })
if err := a.ValidateHTTP(); err == nil {
t.Fatal("external preflight accepted conflicting route limit")
}
var err error
switch entry {
case "RunContext":
err = a.RunContext(context.Background(), "bad address")
case "ServeContext":
l, e := net.Listen("tcp", "127.0.0.1:0")
if e != nil {
t.Fatal(e)
}
err = a.ServeContext(context.Background(), l)
if _, e = l.Accept(); !errors.Is(e, net.ErrClosed) {
t.Fatal("listener leaked", e)
}
case "RunWith":
err = a.RunWith(context.Background(), runnerFunc(func(context.Context, http.Handler, string) error { t.Error("runner started"); return nil }))
case "NewServer":
func() {
defer func() {
if p := recover(); p != nil {
err, _ = p.(error)
}
}()
a.NewServer("")
}()
_ = a.Close()
case "Engine":
w := httptest.NewRecorder()
a.Engine().ServeHTTP(w, httptest.NewRequest("POST", "/v1/files/upload/", nil))
if w.Code != 503 {
t.Fatal(w.Code)
}
err = a.validateErr
_ = a.Close()
}
for _, part := range []string{"POST /v1/files/upload/", "16777216", "8388608", "http.maxBodyBytes"} {
if err == nil || !strings.Contains(err.Error(), part) {
t.Fatalf("missing %q: %v", part, err)
}
}
if closed != 1 {
t.Fatal("cleanup count", closed)
}
})
}
}
func TestDefaultBodyLimitRegistrationPaths(t *testing.T) {
a := New(config.WithHTTPConfig(config.HTTPConfig{DefaultMaxBodyBytes: 4, MaxBodyBytes: 16}))
h := func(*httpx.Context) error { t.Error("oversize reached business"); return nil }
encoder, _ := response.New(response.Config{})
a.Handle("POST", "/handle", h)
a.HandleWithResponse("POST", "/response", encoder, h)
a.Any("/any", h)
g := a.Group("/group")
g.POST("/post", h)
g.Any("/any", h)
g.HandleWithResponse("POST", "/response", encoder, h)
for _, path := range []string{"/handle", "/response", "/any", "/group/post", "/group/any", "/group/response"} {
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", path, strings.NewReader("12345"))
a.ServeHTTP(w, r)
if w.Code != 413 {
t.Fatal(path, w.Code, w.Body.String())
}
}
}

187
app/cors_test.go Normal file
View File

@@ -0,0 +1,187 @@
package app_test
import (
"context"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/config"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/internal/testutil"
"git.yuntuo.hk/k2safe/go-omni/response"
)
func corsApp() *omni.App {
return omni.New(omni.WithCORS(omni.CORSConfig{Enabled: true, AllowedOrigins: []string{"https://web.example"}, AllowedMethods: []string{"GET", "POST", "PUT"}, AllowedHeaders: []string{"Authorization", "Content-Type"}, ExposedHeaders: []string{"Content-Disposition"}}), omni.WithResponse(response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 999, MessageField: "msg", DataMode: "always"}))
}
func TestAppCORSRoutingAndErrors(t *testing.T) {
a := corsApp()
defer a.Close()
var middlewareCalls int
a.Use(func(next omni.Handler) omni.Handler {
return func(c *omni.Context) error { middlewareCalls++; return next(c) }
})
a.GET("/secure", func(c *omni.Context) error { return c.OK("private") }, func(next omni.Handler) omni.Handler {
return func(c *omni.Context) error { return omni.Unauthorized("LOGIN_REQUIRED", "login required") }
})
a.GET("/failure", func(c *omni.Context) error { return omni.Internal(errors.New("private detail")) })
a.GET("/panic", func(c *omni.Context) error { panic("private panic") })
a.Handle("OPTIONS", "/options", func(c *omni.Context) error { return c.Text(200, "ordinary options") })
for name, entry := range map[string]http.Handler{"App": a, "Engine": a.Engine(), "NewServer": a.NewServer("").Handler} {
t.Run(name, func(t *testing.T) {
for _, tc := range []struct {
method, path string
status int
}{{"GET", "/secure", 401}, {"GET", "/failure", 500}, {"GET", "/panic", 500}, {"GET", "/absent", 404}, {"PUT", "/secure", 405}} {
r := httptest.NewRequest(tc.method, tc.path, nil)
r.Header.Set("Origin", "https://web.example")
w := httptest.NewRecorder()
entry.ServeHTTP(w, r)
var body map[string]any
err := json.Unmarshal(w.Body.Bytes(), &body)
if err != nil || w.Code != tc.status || w.Header().Get("Access-Control-Allow-Origin") != "https://web.example" || body["code"] != float64(999) || body["data"] != nil || body["msg"] == nil || strings.Contains(w.Body.String(), "private") {
t.Fatal(w.Code, w.Header(), w.Body.String(), err)
}
}
for _, path := range []string{"/secure", "/absent"} {
before := middlewareCalls
r := httptest.NewRequest("OPTIONS", path, nil)
r.Header.Set("Origin", "https://web.example")
r.Header.Set("Access-Control-Request-Method", "POST")
r.Header.Set("Access-Control-Request-Headers", "authorization,content-type")
w := httptest.NewRecorder()
entry.ServeHTTP(w, r)
if w.Code != 204 || w.Body.Len() != 0 || middlewareCalls != before {
t.Fatal(w.Code, w.Body.String(), middlewareCalls, before)
}
}
r := httptest.NewRequest("OPTIONS", "/options", nil)
w := httptest.NewRecorder()
entry.ServeHTTP(w, r)
if w.Code != 200 || w.Body.String() != "ordinary options" {
t.Fatal(w.Code, w.Body.String())
}
})
}
}
func TestAppCORSConfigIsolation(t *testing.T) {
cfg := config.Default()
cfg.CORS = omni.CORSConfig{Enabled: true, AllowedOrigins: []string{"https://one.example"}, AllowedHeaders: []string{"Authorization"}}
option := omni.WithConfig(cfg)
cfg.CORS.AllowedOrigins[0] = "https://evil.example"
a := omni.New(option)
defer a.Close()
b := omni.New(option)
defer b.Close()
a.Config().CORS.AllowedOrigins[0] = "https://evil.example"
for _, app := range []*omni.App{a, b} {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/absent", nil)
r.Header.Set("Origin", "https://one.example")
app.ServeHTTP(w, r)
if w.Header().Get("Access-Control-Allow-Origin") != "https://one.example" {
t.Fatal(w.Header())
}
}
defer func() {
if recover() == nil {
t.Error("invalid CORS passed direct New")
}
}()
omni.New(omni.WithCORS(omni.CORSConfig{Enabled: true}))
}
func TestCORSTransports(t *testing.T) {
for _, transport := range []string{omni.TransportNetHTTP, omni.TransportNative, omni.TransportHTTP2} {
t.Run(transport, func(t *testing.T) {
cert, key, tlsClient := testutil.CertificateFiles(t)
cfg := omni.CORSConfig{Enabled: true, AllowedOrigins: []string{"https://web.example"}, AllowedMethods: []string{"GET", "POST"}, AllowedHeaders: []string{"Content-Type"}, ExposedHeaders: []string{"Content-Disposition"}}
a := omni.New(omni.WithCORS(cfg), omni.WithTransport(transport), omni.WithTLS(cert, key))
var called atomic.Int32
a.Use(func(next omni.Handler) omni.Handler {
return func(c *omni.Context) error { called.Add(1); return next(c) }
})
a.POST("/upload", func(c *omni.Context) error {
b, err := io.ReadAll(c.Request().Body)
if err != nil {
return err
}
return c.Text(200, string(b))
})
a.GET("/download", func(c *omni.Context) error {
return c.WriteStream(200, "application/octet-stream", &httpx.Stream{Content: io.NopCloser(strings.NewReader("file bytes")), Name: "example.txt"})
})
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- a.ServeContext(ctx, listener) }()
defer func() {
cancel()
select {
case err := <-done:
if err != nil {
t.Error(err)
}
case <-time.After(5 * time.Second):
t.Error("server did not close")
listener.Close()
}
}()
client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{TLSClientConfig: tlsClient, ForceAttemptHTTP2: true}}
defer client.CloseIdleConnections()
url := "https://" + listener.Addr().String()
for _, tc := range []struct {
method, path, body string
status int
}{{"OPTIONS", "/upload", "", 204}, {"POST", "/upload", "raw upload", 200}, {"GET", "/download", "file bytes", 200}, {"GET", "/missing", "", 404}} {
var input io.Reader
if tc.method == "POST" {
input = strings.NewReader(tc.body)
}
r, _ := http.NewRequest(tc.method, url+tc.path, input)
r.Header.Set("Origin", "https://web.example")
if tc.method == "OPTIONS" {
r.Header.Set("Access-Control-Request-Method", "POST")
r.Header.Set("Access-Control-Request-Headers", "content-type")
}
res, err := client.Do(r)
if err != nil {
t.Fatal(err)
}
body, err := io.ReadAll(res.Body)
res.Body.Close()
if err != nil {
t.Fatal(err)
}
if res.StatusCode != tc.status || res.Header.Get("Access-Control-Allow-Origin") != "https://web.example" {
t.Fatal(res.StatusCode, res.Header)
}
if tc.status == 200 && string(body) != tc.body {
t.Fatal(string(body))
}
if tc.status == 204 && (len(body) != 0 || called.Load() != 0) {
t.Fatal("preflight reached middleware")
}
if tc.path == "/download" && (!strings.Contains(res.Header.Get("Content-Disposition"), "example.txt") || res.Header.Get("Access-Control-Expose-Headers") != "Content-Disposition") {
t.Fatal(res.Header)
}
if transport == omni.TransportHTTP2 && res.ProtoMajor != 2 {
t.Fatal(res.Proto)
}
}
})
}
}

178
app/errorCatalog_test.go Normal file
View File

@@ -0,0 +1,178 @@
package app_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
omni "git.yuntuo.hk/k2safe/go-omni"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/logx"
"git.yuntuo.hk/k2safe/go-omni/metrics"
"git.yuntuo.hk/k2safe/go-omni/middleware"
"git.yuntuo.hk/k2safe/go-omni/response"
)
func TestAppErrorCatalogHTTP200LogsAndReplay(t *testing.T) {
var logs bytes.Buffer
logCfg := logx.Default()
logCfg.Writer = &logs
l, err := logx.New(logCfg)
if err != nil {
t.Fatal(err)
}
logx.SetDefault(l)
defer logx.SetDefault(nil)
defer l.Close()
cat := apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{"INVALID": {Code: 400002, Message: "项目提示"}, "DENIED": {Status: 403, Code: 9, Message: "no"}}}
policy := response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 9999, MessageField: "msg", DataMode: "always"}
app := omni.New(omni.WithErrors(cat), omni.WithResponse(policy), omni.WithLogger(true))
defer app.Close()
meter := metrics.New()
app.Use(meter.Middleware())
cat.Definitions["INVALID"] = apierrors.Definition{Status: 500, Code: 1, Message: "mutated"}
app.Config().Errors.Definitions["INVALID"] = apierrors.Definition{Status: 500, Code: 2, Message: "mutated"}
app.GET("/error", func(c *omni.Context) error {
return fmt.Errorf("wrapped: %w", apierrors.ByCode("INVALID").WithCause(errors.New("private")))
})
app.GET("/direct", func(c *omni.Context) error { c.WriteError(apierrors.ByCode("INVALID")); return nil })
app.GET("/bridge", func(c *omni.Context) error { return apierrors.ByCode("INVALID") }, httpx.WrapMiddleware(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next.ServeHTTP(w, r) })
}))
app.GET("/missing", func(c *omni.Context) error { return apierrors.ByCode("MISSING") })
app.GET("/denied", func(c *omni.Context) error { return apierrors.ByCode("DENIED") })
app.GET("/committed", func(c *omni.Context) error {
if err := c.Text(201, "done"); err != nil {
return err
}
return apierrors.ByCode("INVALID")
})
app.HandleWithResponse("GET", "/callback", response.EncoderFunc(func(r response.Result) (response.Payload, error) {
if !r.IsError() || r.BusinessCode != int64(400002) {
t.Fatal(r)
}
return response.Text(202, "ACK"), nil
}), func(c *omni.Context) error { return apierrors.ByCode("INVALID") })
idem, err := middleware.NewIdempotency(middleware.IdempotencyConfig{Scope: func(*httpx.Context) (string, error) { return "fixture", nil }})
if err != nil {
t.Fatal(err)
}
calls := 0
app.POST("/idempotent", func(c *omni.Context) error { calls++; return apierrors.ByCode("INVALID") }, idem.Middleware())
shared, err := middleware.NewIdempotency(middleware.IdempotencyConfig{Store: &catalogStore{records: map[string]middleware.IdempotencyRecord{}}, Scope: func(*httpx.Context) (string, error) { return "fixture", nil }})
if err != nil {
t.Fatal(err)
}
app.POST("/shared", func(c *omni.Context) error { calls++; return apierrors.ByCode("INVALID") }, shared.Middleware())
for _, path := range []string{"/error", "/direct", "/bridge", "/idempotent", "/idempotent", "/shared", "/shared"} {
method := "GET"
if path == "/idempotent" || path == "/shared" {
method = "POST"
}
w := httptest.NewRecorder()
r := httptest.NewRequest(method, path, nil)
r.Header.Set("Idempotency-Key", "same")
app.ServeHTTP(w, r)
if w.Code != 200 || !strings.Contains(w.Body.String(), `"code":400002`) || !strings.Contains(w.Body.String(), "项目提示") || strings.Contains(w.Body.String(), "private") {
t.Fatal(w.Code, w.Body.String())
}
}
if stats := meter.Snapshot(); stats.Errors != 7 || stats.StatusCounts["200"] != 7 {
t.Fatal("lost business failure metrics", stats)
}
if calls != 2 {
t.Fatal("replay executed business twice")
}
for _, test := range []struct {
path string
status int
body string
}{{"/denied", 403, `"code":9`}, {"/missing", 500, `"code":9999`}, {"/committed", 201, "done"}, {"/callback", 202, "ACK"}} {
w := httptest.NewRecorder()
app.ServeHTTP(w, httptest.NewRequest("GET", test.path, nil))
if w.Code != test.status || !strings.Contains(w.Body.String(), test.body) {
t.Fatal(test.path, w.Code, w.Body.String())
}
}
for _, line := range strings.Split(strings.TrimSpace(logs.String()), "\n") {
var entry map[string]any
if err := json.Unmarshal([]byte(line), &entry); err != nil {
t.Fatal(err)
}
if entry["msg"] != "http.request" {
continue
}
if entry["failed"] != true {
t.Fatal("lost failure:", entry)
}
if entry["path"] == "/callback" {
if entry["status"] != float64(202) {
t.Fatal(entry)
}
} else if entry["path"] == "/error" || entry["path"] == "/direct" || entry["path"] == "/bridge" || entry["path"] == "/idempotent" || entry["path"] == "/shared" {
if entry["status"] != float64(200) || entry["businessCode"] != float64(400002) || entry["errorKey"] != "INVALID" {
t.Fatal(entry)
}
}
}
// A second application may interpret the same logical identifier differently.
app2 := omni.New(omni.WithErrors(apierrors.Catalog{Definitions: map[string]apierrors.Definition{"INVALID": {Status: 409, Code: "conflict", Message: "other"}}}))
defer app2.Close()
app2.GET("/", func(*omni.Context) error { return apierrors.ByCode("INVALID") })
w := httptest.NewRecorder()
app2.ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
if w.Code != 409 || !strings.Contains(w.Body.String(), "other") {
t.Fatal(w.Code, w.Body.String())
}
}
// A serialized store exercises the shared middleware contract, including replay
// metadata. No external service or project database is used by this fixture.
type catalogStore struct {
mu sync.Mutex
records map[string]middleware.IdempotencyRecord
}
func (s *catalogStore) Claim(_ context.Context, key, fp, owner string) (middleware.IdempotencyRecord, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
if r, ok := s.records[key]; ok {
return r, false, nil
}
r := middleware.IdempotencyRecord{Fingerprint: fp, Owner: owner, State: "running"}
s.records[key] = r
return r, true, nil
}
func (s *catalogStore) Complete(_ context.Context, key, owner string, response *middleware.StoredResponse, _ time.Duration) error {
s.mu.Lock()
defer s.mu.Unlock()
r := s.records[key]
if r.Owner != owner {
return errors.New("wrong owner")
}
r.State = "failed"
if response != nil {
r.State = "completed"
encoded, err := json.Marshal(response)
if err != nil {
return err
}
var copy middleware.StoredResponse
if err := json.Unmarshal(encoded, &copy); err != nil {
return err
}
r.Response = &copy
}
s.records[key] = r
return nil
}

View File

@@ -0,0 +1,126 @@
package app_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
omni "git.yuntuo.hk/k2safe/go-omni"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/response"
)
func TestRouteInheritsAppErrorCatalog(t *testing.T) {
catalog := apierrors.Catalog{DefaultStatus: 400, Definitions: map[string]apierrors.Definition{
"USERNAME_INVALID": {Code: 400002, Message: "用户名格式不正确"},
"TEXT_TOO_LONG": {Status: 200, Code: 400003, Message: "{label}不能超过{max}个字符", Params: []apierrors.Parameter{{Name: "label", Type: "string"}, {Name: "max", Type: "int"}}},
"INTERNAL_ERROR": {Status: 500, Code: 9999, Message: "系统错误"},
}}
format := response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 9999, MessageField: "msg", DataMode: "always"}
app := omni.New(omni.WithErrors(catalog), omni.WithResponse(format))
defer app.Close()
getError := func(c *httpx.Context) error {
key := c.Header("X-Error")
if key == "TEXT_TOO_LONG" {
return apierrors.ByCode(key).WithParams(map[string]any{"label": "企业名称", "max": 100})
}
return apierrors.ByCode(key)
}
app.Use(func(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error {
if c.Header("X-Early") == "true" {
return getError(c)
}
return next(c)
}
})
bridge := httpx.WrapMiddleware(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next.ServeHTTP(w, r) })
})
app.GET("/plain", getError)
// This is exactly the wrapper emitted by v0.11.0, not just the new generator.
legacy := response.MustWithErrors(apierrors.Catalog{}, response.MustNew(format))
app.HandleWithResponse("GET", "/generated", legacy, getError, bridge)
app.HandleWithResponse("GET", "/raw", response.RawJSON(legacy), getError, bridge)
for _, path := range []string{"/plain", "/generated", "/raw"} {
for _, early := range []string{"false", "true"} {
for _, test := range []struct {
key string
status, code int
message string
}{
{"USERNAME_INVALID", 400, 400002, "用户名格式不正确"},
{"TEXT_TOO_LONG", 200, 400003, "企业名称不能超过100个字符"},
{"MISSING", 500, 9999, "系统错误"},
} {
t.Run(path+"/"+early+"/"+test.key, func(t *testing.T) {
r := httptest.NewRequest("GET", path, nil)
r.Header.Set("X-Early", early)
r.Header.Set("X-Error", test.key)
w := httptest.NewRecorder()
app.ServeHTTP(w, r)
var value map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &value); err != nil {
t.Fatal(err, w.Body.String())
}
if w.Code != test.status || value["code"] != float64(test.code) || value["msg"] != test.message {
t.Fatal(w.Code, value)
}
if data, ok := value["data"]; !ok || data != nil {
t.Fatal("data:null lost", value)
}
})
}
}
}
}
func TestExplicitRouteErrorCatalogRemainsIndependent(t *testing.T) {
format := response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 9999, MessageField: "msg", DataMode: "always"}
global := apierrors.Catalog{DefaultStatus: 400, Definitions: map[string]apierrors.Definition{
"INVALID": {Code: 400002, Message: "全局提示"}, "APP_ONLY": {Code: 400003, Message: "全局独有"},
"INTERNAL_ERROR": {Status: 500, Code: 9999, Message: "全局系统错误"},
}}
local := apierrors.Catalog{DefaultStatus: 409, Definitions: map[string]apierrors.Definition{
"INVALID": {Code: 409001, Message: "路由提示"}, "INTERNAL_ERROR": {Status: 500, Code: 9998, Message: "路由系统错误"},
}}
app := omni.New(omni.WithErrors(global), omni.WithResponse(format))
defer app.Close()
getError := func(c *httpx.Context) error { return apierrors.ByCode(c.Header("X-Error")) }
app.Use(func(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error {
if c.Header("X-Early") == "true" {
return getError(c)
}
return next(c)
}
})
routeFormat := format
routeFormat.MessageField = "detail"
localEncoder := response.MustWithErrors(local, response.MustNew(routeFormat))
app.HandleWithResponse("GET", "/", response.RawJSON(localEncoder), getError)
for _, early := range []string{"false", "true"} {
for _, test := range []struct {
key string
status, code int
message string
}{
{"INVALID", 409, 409001, "路由提示"}, {"APP_ONLY", 500, 9998, "路由系统错误"},
} {
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("X-Early", early)
r.Header.Set("X-Error", test.key)
w := httptest.NewRecorder()
app.ServeHTTP(w, r)
var value map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &value); err != nil {
t.Fatal(err)
}
if w.Code != test.status || value["code"] != float64(test.code) || value["detail"] != test.message || value["msg"] != nil {
t.Fatal(w.Code, value)
}
}
}
}

186
app/errorParams_test.go Normal file
View File

@@ -0,0 +1,186 @@
package app_test
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
omni "git.yuntuo.hk/k2safe/go-omni"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/logx"
"git.yuntuo.hk/k2safe/go-omni/metrics"
"git.yuntuo.hk/k2safe/go-omni/middleware"
"git.yuntuo.hk/k2safe/go-omni/response"
)
func TestTemplateErrorsAcrossPoliciesAndReplay(t *testing.T) {
var logs bytes.Buffer
lc := logx.Default()
lc.Writer = &logs
logger, err := logx.New(lc)
if err != nil {
t.Fatal(err)
}
logx.SetDefault(logger)
defer logx.SetDefault(nil)
defer logger.Close()
cat := apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{"TOO_LONG": {
Code: 400002, Message: "{label}不能超过{max}个字符", Params: []apierrors.Parameter{{Name: "label", Type: "string"}, {Name: "max", Type: "int"}},
}}}
registry := apierrors.MustRegistry(cat)
encoder := response.MustWithErrors(cat, response.MustNew(response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 9999, MessageField: "msg", DataMode: "always"}))
app := omni.New(omni.WithLogger(true))
defer app.Close()
meter := metrics.New()
app.Use(meter.Middleware())
// The clone uses the route policy even though the App has a different default.
app.Use(func(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error {
err := next(c)
if f := c.Failure(); f != nil && strings.Contains(f.Message, "{label}") {
t.Error("unrendered failure", f)
}
return err
}
})
bridge := httpx.WrapMiddleware(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next.ServeHTTP(w, r) })
})
calls := 0
handler := func(c *httpx.Context) error {
calls++
values := map[string]any{"label": "企业名称", "max": 100}
if c.Header("X-Label") == "remark" {
values = map[string]any{"label": "备注", "max": 500}
}
switch c.Header("X-Failure") {
case "missing":
delete(values, "max")
case "type":
values["max"] = "private-value"
}
e := apierrors.ByCode("TOO_LONG").WithParams(values).WithCause(errors.New("private cause"))
if c.Header("X-Resolved") == "true" {
return registry.Resolve(e)
}
return e
}
app.HandleWithResponse("POST", "/direct", encoder, handler, bridge)
for _, shared := range []bool{false, true} {
cfg := middleware.IdempotencyConfig{Scope: func(*httpx.Context) (string, error) { return "fixture", nil }}
path := "/local"
if shared {
path = "/shared"
cfg.Store = &catalogStore{records: map[string]middleware.IdempotencyRecord{}}
}
idem, err := middleware.NewIdempotency(cfg)
if err != nil {
t.Fatal(err)
}
app.HandleWithResponse("POST", path, encoder, handler, idem.Middleware(), bridge)
}
check := func(path, label, key, kind string, status int, want string) *httptest.ResponseRecorder {
t.Helper()
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", path, nil)
r.Header.Set("Idempotency-Key", key)
r.Header.Set("X-Label", label)
r.Header.Set("X-Failure", kind)
r.Header.Set("X-Resolved", "true")
app.ServeHTTP(w, r)
if w.Code != status || !strings.Contains(w.Body.String(), want) || strings.Contains(w.Body.String(), "private") || strings.Contains(w.Body.String(), "{label}") {
t.Fatal(path, w.Code, w.Body.String())
}
if status == 200 && (!strings.Contains(w.Body.String(), `"data":null`) || !strings.Contains(w.Body.String(), `"code":400002`)) {
t.Fatal(w.Body.String())
}
return w
}
for _, path := range []string{"/direct", "/local", "/shared"} {
for _, label := range []string{"company", "remark"} {
want := "企业名称不能超过100个字符"
if label == "remark" {
want = "备注不能超过500个字符"
}
first := check(path, label, label, "", 200, want)
if path != "/direct" {
before := calls
second := check(path, label, label, "", 200, want)
if calls != before || first.Body.String() != second.Body.String() || second.Header().Get("Idempotency-Replayed") != "true" {
t.Fatal("bad replay")
}
}
}
}
check("/direct", "company", "", "missing", 500, "internal server error")
check("/direct", "company", "", "type", 500, "internal server error")
stats := meter.Snapshot()
if stats.Errors != 12 || stats.StatusCounts["200"] != 10 || stats.StatusCounts["500"] != 2 {
t.Fatal(stats)
}
seen, diagnostics := 0, 0
for _, line := range strings.Split(strings.TrimSpace(logs.String()), "\n") {
var entry map[string]any
if err := json.Unmarshal([]byte(line), &entry); err != nil {
t.Fatal(err)
}
if entry["msg"] != "http.request" {
continue
}
seen++
if entry["failed"] != true {
t.Fatal(entry)
}
if entry["status"] == float64(200) && (entry["errorKey"] != "TOO_LONG" || entry["businessCode"] != float64(400002)) {
t.Fatal(entry)
}
if entry["errorParameter"] != nil {
diagnostics++
if strings.Contains(entry["errorParameter"].(string), "private-value") {
t.Fatal(entry)
}
}
}
if seen != 12 || diagnostics != 2 {
t.Fatal(seen, diagnostics)
}
}
func TestResolvedFailureReplaySurvivesCatalogChanges(t *testing.T) {
store := &catalogStore{records: map[string]middleware.IdempotencyRecord{}}
var observed []*apierrors.APIError
makeApp := func(message string) *omni.App {
cat := apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{"T": {Code: "code", Message: message, Params: []apierrors.Parameter{{Name: "label", Type: "string"}}}}}
app := omni.New(omni.WithErrors(cat))
app.Use(func(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error { err := next(c); observed = append(observed, c.Failure()); return err }
})
idem, err := middleware.NewIdempotency(middleware.IdempotencyConfig{Store: store, Scope: func(*httpx.Context) (string, error) { return "fixture", nil }})
if err != nil {
t.Fatal(err)
}
app.POST("/", func(c *httpx.Context) error {
return apierrors.ByCode("T").WithParams(map[string]any{"label": "企业"})
}, idem.Middleware())
return app
}
for _, message := range []string{"{label}原文案", "{label}新文案"} {
app := makeApp(message)
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/", nil)
r.Header.Set("Idempotency-Key", "same")
app.ServeHTTP(w, r)
app.Close()
if !strings.Contains(w.Body.String(), "企业原文案") {
t.Fatal(w.Body.String())
}
}
if len(observed) != 2 || observed[0].Message != "企业原文案" || observed[1].Message != "企业原文案" || observed[1].Code != "T" {
t.Fatal(observed)
}
}

119
app/fatalShutdown_test.go Normal file
View File

@@ -0,0 +1,119 @@
package app
import (
"context"
"errors"
"io"
"net"
"net/http"
"sync"
"sync/atomic"
"testing"
"time"
"git.yuntuo.hk/k2safe/go-omni/config"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
)
func TestFatalShutdownDrainsAndPreservesCause(t *testing.T) {
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative} {
t.Run(transport, func(t *testing.T) {
app := New(config.WithTransport(transport), config.WithShutdownTimeout(2*time.Second))
control := lifecycle.New(context.Background())
defer control.RequestShutdown(nil)
entered, release := make(chan struct{}), make(chan struct{})
var releaseOnce sync.Once
unblock := func() { releaseOnce.Do(func() { close(release) }) }
defer unblock()
var closed atomic.Int32
closeErr := errors.New("cleanup diagnostic")
app.OnShutdown(func() error { closed.Add(1); return closeErr })
app.GET("/work", func(c *httpx.Context) error {
close(entered)
<-release
if closed.Load() != 0 {
return errors.New("closed during request")
}
return c.OK("done")
})
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() { done <- app.ServeContext(control.Context(), listener) }()
requestDone := make(chan error, 1)
client := http.Client{Timeout: 3 * time.Second}
go func() {
res, err := client.Get("http://" + listener.Addr().String() + "/work")
if err == nil {
_, err = io.Copy(io.Discard, res.Body)
_ = res.Body.Close()
if res.StatusCode != 200 {
err = errors.New("request failed")
}
}
requestDone <- err
}()
select {
case <-entered:
case <-time.After(3 * time.Second):
t.Fatal("request never started")
}
fatal := errors.New("lease lost")
control.RequestShutdown(fatal)
select {
case err := <-done:
t.Fatal("did not drain", err)
case <-time.After(30 * time.Millisecond):
}
if closed.Load() != 0 {
t.Fatal("closed dependency before drain")
}
unblock()
select {
case err := <-requestDone:
if err != nil {
t.Fatal(err)
}
case <-time.After(3 * time.Second):
t.Fatal("request stuck")
}
select {
case err := <-done:
if !errors.Is(err, fatal) || !errors.Is(err, closeErr) {
t.Fatal("lost cause", err)
}
case <-time.After(3 * time.Second):
t.Fatal("shutdown stuck")
}
_ = app.Close()
if closed.Load() != 1 {
t.Fatal("cleanup repeated")
}
})
}
}
func TestCanceledStartupPreservesFault(t *testing.T) {
fatal := errors.New("dependency failed during startup")
ctx, cancel := context.WithCancelCause(context.Background())
cancel(fatal)
app := New()
if err := app.RunContext(ctx); !errors.Is(err, fatal) {
t.Fatal(err)
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
if err = New().ServeContext(ctx, listener); !errors.Is(err, fatal) {
t.Fatal(err)
}
normal, stop := context.WithCancel(context.Background())
stop()
if err = New().RunContext(normal); err != nil {
t.Fatal("normal stop failed", err)
}
}

199
app/httpConfig_test.go Normal file
View File

@@ -0,0 +1,199 @@
package app_test
import (
"bufio"
"context"
"errors"
"io"
"log"
"net"
"net/http"
"strings"
"sync/atomic"
"testing"
"time"
omni "git.yuntuo.hk/k2safe/go-omni"
)
func TestHTTPConfiguredStandardServer(t *testing.T) {
cfg := omni.HTTPConfig{ReadHeaderTimeout: time.Second, ReadTimeout: 2 * time.Second, WriteTimeout: 3 * time.Second, IdleTimeout: 4 * time.Second, MaxHeaderBytes: 8192, ErrorLog: log.New(io.Discard, "", 0)}
a := omni.New(omni.WithHTTPConfig(cfg))
s := a.NewServer(":9000")
if s.ReadHeaderTimeout != cfg.ReadHeaderTimeout || s.ReadTimeout != cfg.ReadTimeout || s.WriteTimeout != cfg.WriteTimeout || s.IdleTimeout != cfg.IdleTimeout || s.MaxHeaderBytes != cfg.MaxHeaderBytes || s.ErrorLog != cfg.ErrorLog || s.Addr != ":9000" {
t.Fatalf("server options: %+v", s)
}
s = omni.New().NewServer("")
if s.ReadHeaderTimeout != 0 || s.ReadTimeout != 0 || s.WriteTimeout != 0 || s.IdleTimeout != 0 || s.MaxHeaderBytes != 0 || s.ErrorLog != nil {
t.Fatal("default behavior changed")
}
}
func TestHTTPInvalidStartupReleasesResources(t *testing.T) {
a := omni.New(omni.WithHTTPConfig(omni.HTTPConfig{ReadTimeout: -time.Second}))
called := 0
cleanupErr := errors.New("cleanup")
a.OnShutdown(func() error { called++; return cleanupErr })
err := a.RunContext(context.Background(), "bad address")
if err == nil || !strings.Contains(err.Error(), "http.readTimeout") || !errors.Is(err, cleanupErr) || called != 1 {
t.Fatalf("validation/cleanup: %v, %d", err, called)
}
defer func() {
if recover() == nil {
t.Error("NewServer accepted invalid config")
}
}()
a.NewServer("")
}
type httpConfigListener struct{ net.Listener }
func (l httpConfigListener) Accept() (net.Conn, error) {
c, err := l.Listener.Accept()
if err == nil {
if tcp, ok := c.(*net.TCPConn); ok {
_ = tcp.SetWriteBuffer(32 << 10)
}
}
return c, err
}
func httpConfigConnection(t *testing.T, transport string, cfg omni.HTTPConfig, handler omni.Handler) net.Conn {
t.Helper()
a := omni.New(omni.WithTransport(transport), omni.WithHTTPConfig(cfg), omni.WithShutdownTimeout(time.Second))
a.GET("/", handler)
a.POST("/", handler)
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- a.ServeContext(ctx, httpConfigListener{l}) }()
t.Cleanup(func() {
cancel()
select {
case err := <-done:
if err != nil {
t.Errorf("server shutdown: %v", err)
}
case <-time.After(3 * time.Second):
t.Error("server did not stop")
}
})
c, err := net.DialTimeout("tcp", l.Addr().String(), time.Second)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { c.Close() })
_ = c.SetDeadline(time.Now().Add(3 * time.Second))
return c
}
func TestHTTPConfiguredNetworkLimits(t *testing.T) {
for _, transport := range []string{omni.TransportNetHTTP, omni.TransportNative} {
t.Run(transport, func(t *testing.T) {
empty := func(c *omni.Context) error { w := c.Raw(); w.WriteHeader(204); return nil }
t.Run("slowHeaders", func(t *testing.T) {
c := httpConfigConnection(t, transport, omni.HTTPConfig{ReadHeaderTimeout: 60 * time.Millisecond}, empty)
if _, err := io.WriteString(c, "GET / HTTP/1.1\r\nHost: localhost\r\nX-Incomplete:"); err != nil {
t.Fatal(err)
}
_, err := io.ReadAll(c)
if e, ok := err.(net.Error); ok && e.Timeout() {
t.Fatal("server read-header timeout not applied")
}
})
t.Run("slowBody", func(t *testing.T) {
var accepted atomic.Bool
c := httpConfigConnection(t, transport, omni.HTTPConfig{ReadTimeout: 60 * time.Millisecond}, func(c *omni.Context) error {
_, err := io.ReadAll(c.Request().Body)
accepted.Store(err == nil)
return nil
})
if _, err := io.WriteString(c, "POST / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Length: 10\r\n\r\nx"); err != nil {
t.Fatal(err)
}
_, err := io.ReadAll(c)
if e, ok := err.(net.Error); ok && e.Timeout() {
t.Fatal("read timeout not applied")
}
if accepted.Load() {
t.Fatal("incomplete body accepted")
}
})
t.Run("idle", func(t *testing.T) {
c := httpConfigConnection(t, transport, omni.HTTPConfig{IdleTimeout: 60 * time.Millisecond}, empty)
if _, err := io.WriteString(c, "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); err != nil {
t.Fatal(err)
}
r, err := http.ReadResponse(bufio.NewReader(c), nil)
if err != nil {
t.Fatal(err)
}
r.Body.Close()
if r.StatusCode != 204 {
t.Fatal(r.StatusCode)
}
_, err = c.Read(make([]byte, 1))
if e, ok := err.(net.Error); ok && e.Timeout() {
t.Fatal("idle timeout not applied")
}
if err == nil {
t.Fatal("idle connection not closed")
}
})
t.Run("writeBackpressure", func(t *testing.T) {
done := make(chan error, 1)
c := httpConfigConnection(t, transport, omni.HTTPConfig{WriteTimeout: 60 * time.Millisecond}, func(c *omni.Context) error {
w := c.Raw()
w.WriteHeader(200)
if err := http.NewResponseController(w).Flush(); err != nil {
done <- err
return nil
}
_, err := io.Copy(w, io.LimitReader(repeatingHTTPReader{}, 64<<20))
done <- err
return nil
})
if tcp, ok := c.(*net.TCPConn); ok {
_ = tcp.SetReadBuffer(1024)
}
if _, err := io.WriteString(c, "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); err != nil {
t.Fatal(err)
}
select {
case err := <-done:
if err == nil {
t.Fatal("write unexpectedly completed")
}
case <-time.After(2 * time.Second):
t.Fatal("write timeout not applied")
}
})
t.Run("headerLimit", func(t *testing.T) {
c := httpConfigConnection(t, transport, omni.HTTPConfig{MaxHeaderBytes: 1024}, empty)
if _, err := io.WriteString(c, "GET / HTTP/1.1\r\nHost: localhost\r\nX-Large: "+strings.Repeat("a", 8192)+"\r\n\r\n"); err != nil {
t.Fatal(err)
}
r, err := http.ReadResponse(bufio.NewReader(c), nil)
if err != nil {
t.Fatal(err)
}
r.Body.Close()
if r.StatusCode != 431 {
t.Fatalf("oversized header status: %d", r.StatusCode)
}
})
})
}
}
type repeatingHTTPReader struct{}
func (repeatingHTTPReader) Read(b []byte) (int, error) {
for i := range b {
b[i] = 'x'
}
return len(b), nil
}

241
app/i18n_test.go Normal file
View File

@@ -0,0 +1,241 @@
package app_test
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
omni "git.yuntuo.hk/k2safe/go-omni"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/i18n"
"git.yuntuo.hk/k2safe/go-omni/i18n/errori18n"
"git.yuntuo.hk/k2safe/go-omni/i18n/httpi18n"
"git.yuntuo.hk/k2safe/go-omni/middleware"
"git.yuntuo.hk/k2safe/go-omni/response"
)
func localizedCatalog(namespace string, status int) apierrors.Catalog {
return errori18n.Bind(apierrors.Catalog{DefaultStatus: status, Definitions: map[string]apierrors.Definition{
"SAME": {Code: int64(9223372036854775807), Message: namespace + ":{value}", Params: []apierrors.Parameter{{Name: "value", Type: "string"}}},
"INTERNAL_ERROR": {Status: 500, Code: int64(500), Message: "internal"},
"FIXED": {Status: 401, Code: int64(401), Message: "fixed {literal}"},
}}, namespace)
}
func localizedOptions(t *testing.T, catalogs ...apierrors.Catalog) (*httpi18n.Policy, *errori18n.Localizer) {
t.Helper()
docs := []i18n.Document{}
for _, catalog := range catalogs {
source, err := errori18n.Source(catalog, "en")
if err != nil {
t.Fatal(err)
}
docs = append(docs, source...)
for _, d := range source {
docs = append(docs, i18n.Document{Version: 1, Namespace: d.Namespace, Locale: "zh-Hans", Messages: map[string]i18n.Message{
"SAME": {Value: i18n.Pattern{Text: d.Namespace + "译文:{value}"}}, "FIXED": {Value: i18n.Pattern{Text: "需要认证"}}, "INTERNAL_ERROR": {Value: i18n.Pattern{Text: "系统错误"}},
}})
}
}
b, err := i18n.New(i18n.Config{SourceLocale: "en", DefaultLocale: "en", Locales: []string{"en", "zh-Hans"}}, docs...)
if err != nil {
t.Fatal(err)
}
l := errori18n.New(b, nil)
for _, c := range catalogs {
if err := l.Validate(c); err != nil {
t.Fatal(err)
}
}
p, err := httpi18n.New(b)
if err != nil {
t.Fatal(err)
}
return p, l
}
func TestI18nRouteScopesEarlyFailuresAndReplay(t *testing.T) {
global, local := localizedCatalog("app", 200), localizedCatalog("route", 409)
policy, localizer := localizedOptions(t, global, local)
format := response.Config{CodeType: "int64", SuccessCode: int64(0), ErrorCode: int64(999), Body: map[string]any{"error": map[string]any{"reason": "${message}", "id": "${code}"}, "ok": "${ok}", "data": "${data}"}, DataMode: "always", Messages: map[string]string{"FIXED": "fixed {literal}"}}
app := omni.New(omni.WithErrors(global), omni.WithResponse(format), omni.WithLanguagePolicy(policy), omni.WithErrorLocalizer(localizer))
defer app.Close()
private := errors.New("private-value")
app.Use(func(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error {
switch c.Header("X-Early") {
case "auth":
return apierrors.ByCode("FIXED")
case "panic":
panic(private)
}
return next(c)
}
})
handler := func(c *httpx.Context) error {
if c.Header("X-Params") == "missing" {
return apierrors.ByCode("SAME")
}
return apierrors.ByCode("SAME").WithParams(map[string]any{"value": "{literal}"}).WithCause(private)
}
app.GET("/global", handler)
bridge := httpx.WrapMiddleware(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next.ServeHTTP(w, r) })
})
app.HandleWithResponse("GET", "/route", response.RawJSON(response.MustWithErrors(local, response.MustNew(format))), handler, bridge)
for _, path := range []string{"/global", "/route"} {
for _, locale := range []string{"en", "zh-CN"} {
for _, mode := range []string{"", "auth", "panic", "missing"} {
r := httptest.NewRequest("GET", path, nil)
r.Header.Set("Accept-Language", locale)
r.Header.Set("X-Early", mode)
r.Header.Set("X-Params", mode)
w := httptest.NewRecorder()
app.ServeHTTP(w, r)
var body struct {
Error struct {
Reason string `json:"reason"`
ID json.Number `json:"id"`
} `json:"error"`
OK bool `json:"ok"`
Data any `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err, w.Body.String())
}
ns, status := "app", 200
if path == "/route" {
ns, status = "route", 409
}
want := ns + ":{literal}"
if locale != "en" {
want = ns + "译文:{literal}"
}
if mode == "auth" {
status = 401
want = "fixed {literal}"
if locale != "en" {
want = "需要认证"
}
} else if mode == "panic" || mode == "missing" {
status = 500
want = "internal"
if locale != "en" {
want = "系统错误"
}
}
if w.Code != status || body.OK || body.Error.Reason != want || strings.Contains(w.Body.String(), "private-value") {
t.Fatal(path, locale, mode, w.Code, w.Body.String())
}
if mode == "" && body.Error.ID.String() != "9223372036854775807" {
t.Fatal("public code changed", body.Error)
}
}
}
}
for _, shared := range []bool{false, true} {
cfg := middleware.IdempotencyConfig{Scope: func(*httpx.Context) (string, error) { return "test", nil }}
if shared {
cfg.Store = &catalogStore{records: map[string]middleware.IdempotencyRecord{}}
}
idem, err := middleware.NewIdempotency(cfg)
if err != nil {
t.Fatal(err)
}
path := "/localReplay"
if shared {
path = "/sharedReplay"
}
calls := 0
app.POST(path, func(c *httpx.Context) error { calls++; return handler(c) }, idem.Middleware())
var first string
for _, locale := range []string{"en", "zh-CN"} {
r := httptest.NewRequest("POST", path, strings.NewReader(`{}`))
r.Header.Set("Idempotency-Key", "same")
r.Header.Set("Accept-Language", locale)
w := httptest.NewRecorder()
app.ServeHTTP(w, r)
if locale == "en" {
first = w.Body.String()
} else if w.Body.String() != first || w.Header().Get("Idempotency-Replayed") != "true" {
t.Fatal("replay changed", w.Body.String(), first)
}
}
if calls != 1 {
t.Fatal("language retriggered business", calls)
}
}
}
func TestI18nExplicitProtocolsAndConcurrentApps(t *testing.T) {
apps := []*omni.App{}
for _, ns := range []string{"one", "two"} {
c := localizedCatalog(ns, 200)
p, l := localizedOptions(t, c)
a := omni.New(omni.WithErrors(c), omni.WithResponse(response.Config{CodeType: "int64", SuccessCode: 0, ErrorCode: 999}), omni.WithLanguagePolicy(p), omni.WithErrorLocalizer(l))
defer a.Close()
a.GET("/", func(*httpx.Context) error { return apierrors.ByCode("FIXED") })
a.HandleWithResponse("GET", "/ack", response.EncoderFunc(func(r response.Result) (response.Payload, error) {
if !r.IsError() || r.Message != "fixed {literal}" || r.PublicMessage() != "需要认证" {
t.Error(r)
}
return response.Text(200, "ACK"), nil
}), func(*httpx.Context) error { return apierrors.ByCode("FIXED") })
a.GET("/direct", func(c *httpx.Context) error { return c.Text(200, "fixed-protocol") })
apps = append(apps, a)
}
var wg sync.WaitGroup
for n := 0; n < 60; n++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
locale, want := "en", "fixed {literal}"
if n%2 == 0 {
locale, want = "zh-CN", "需要认证"
}
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("Accept-Language", locale)
w := httptest.NewRecorder()
apps[n%len(apps)].ServeHTTP(w, r)
if w.Code != 401 || !strings.Contains(w.Body.String(), want) {
t.Error(w.Code, w.Body.String())
}
}(n)
}
wg.Wait()
for _, path := range []string{"/ack", "/direct"} {
r := httptest.NewRequest("GET", path, nil)
r.Header.Set("Accept-Language", "zh-CN")
w := httptest.NewRecorder()
apps[0].ServeHTTP(w, r)
want := "ACK"
if path == "/direct" {
want = "fixed-protocol"
}
if w.Body.String() != want {
t.Fatal(w.Body.String())
}
}
}
type panickingLocalizer struct{}
func (panickingLocalizer) Localize(context.Context, *apierrors.Registry, *apierrors.APIError) (string, bool) {
panic("presentation failed")
}
func TestI18nAdapterFailurePreservesBusiness(t *testing.T) {
c := localizedCatalog("test", 200)
a := omni.New(omni.WithErrors(c), omni.WithResponse(response.Config{CodeType: "int64", SuccessCode: 0, ErrorCode: 999}), omni.WithErrorLocalizer(panickingLocalizer{}))
defer a.Close()
a.GET("/", func(*httpx.Context) error { return apierrors.ByCode("FIXED") })
w := httptest.NewRecorder()
a.ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
if w.Code != 401 || !strings.Contains(w.Body.String(), "fixed {literal}") {
t.Fatal(w.Code, w.Body.String())
}
}

58
app/logIsolation_test.go Normal file
View File

@@ -0,0 +1,58 @@
package app_test
import (
"bytes"
"context"
"net/http/httptest"
"strings"
"testing"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
func TestAppLogAndRequestMetadataIsolation(t *testing.T) {
var a, b bytes.Buffer
la, _ := logx.New(logx.Config{Writer: &a})
lb, _ := logx.New(logx.Config{Writer: &b})
apps := []*omni.App{
omni.New(omni.WithLogger(true), omni.WithLogInstance(la), omni.WithTrustedProxies("127.0.0.1/32")),
omni.New(omni.WithLogger(true), omni.WithLogInstance(lb)),
}
for i, app := range apps {
app.GET("/probe/:id", func(c *httpx.Context) error {
ctx := c.Context()
meta := httpx.Metadata(ctx)
expected := "127.0.0.1"
if i == 0 {
expected = "203.0.113.7"
}
if meta.ClientIP != expected || meta.Route != "/probe/:id" || meta.Method != "GET" {
t.Error(meta)
}
logx.InfoContext(ctx, "business", "app", i)
clone := c.Clone(context.WithValue(ctx, struct{}{}, true), httptest.NewRecorder())
if httpx.Metadata(clone.Context()) != meta {
t.Error("metadata lost")
}
return c.OK(nil)
})
}
for _, app := range apps {
r := httptest.NewRequest("GET", "/probe/42", nil)
r.RemoteAddr = "127.0.0.1:1234"
r.Header.Set("X-Forwarded-For", "203.0.113.7")
app.ServeHTTP(httptest.NewRecorder(), r)
}
for i, out := range []string{a.String(), b.String()} {
if !strings.Contains(out, "http.request") || !strings.Contains(out, "business") || strings.Contains(out, `"app":`+string(rune('0'+(1-i)))) {
t.Fatal(out)
}
}
_ = apps[0].Close()
lb.Info("still open")
if !strings.Contains(b.String(), "still open") {
t.Fatal("borrowed logger closed")
}
}

88
app/requests.go Normal file
View File

@@ -0,0 +1,88 @@
package app
import (
"context"
"errors"
"fmt"
"sync"
"time"
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
)
// Admission and completion share a lock: once stopped, no request can enter
// between observing zero active handlers and releasing their dependencies.
type requestDrain struct {
mu sync.Mutex
stopped bool
active map[*requestWork]struct{}
done chan struct{}
}
type requestWork struct{ cancel context.CancelFunc }
func (d *requestDrain) begin(ctx context.Context) (context.Context, *requestWork) {
d.mu.Lock()
defer d.mu.Unlock()
if d.stopped {
return ctx, nil
}
ctx, cancel := context.WithCancel(ctx)
w := &requestWork{cancel: cancel}
if d.active == nil {
d.active = make(map[*requestWork]struct{})
}
d.active[w] = struct{}{}
return ctx, w
}
func (d *requestDrain) end(w *requestWork) {
w.cancel()
d.mu.Lock()
defer d.mu.Unlock()
delete(d.active, w)
if d.stopped && len(d.active) == 0 {
close(d.done)
}
}
func (d *requestDrain) stop() {
d.mu.Lock()
defer d.mu.Unlock()
if d.stopped {
return
}
d.stopped = true
if len(d.active) == 0 {
close(d.done)
}
}
func (d *requestDrain) cancelAndWait(timeout time.Duration) error {
d.stop()
d.mu.Lock()
for w := range d.active {
w.cancel()
}
d.mu.Unlock()
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-d.done:
return nil
case <-timer.C:
select {
case <-d.done:
return nil
default:
}
return fmt.Errorf("go-omni: HTTP handlers did not finish within cancellation grace %s: %w", timeout,
errors.Join(context.DeadlineExceeded, lifecycle.ErrResourcesRetained))
}
}
// watchShutdown stops admission on the early signal without canceling accepted
// work. The transport retains its graceful drain budget before Close cancels.
func (a *App) watchShutdown(ctx context.Context) func() bool {
return context.AfterFunc(ctx, a.requests.stop)
}

167
app/requests_test.go Normal file
View File

@@ -0,0 +1,167 @@
package app
import (
"context"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
"git.yuntuo.hk/k2safe/go-omni/config"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/internal/testutil"
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
)
func TestShutdownWaitsForCancellationCleanup(t *testing.T) {
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
t.Run(transport, func(t *testing.T) {
cert, key, tlsClient := testutil.CertificateFiles(t)
a := New(config.WithTransport(transport), config.WithTLS(cert, key), config.WithShutdownTimeout(30*time.Millisecond), config.WithHTTPConfig(config.HTTPConfig{ShutdownCancelTimeout: time.Second}))
started, canceled, release := make(chan struct{}), make(chan struct{}), make(chan struct{})
var once sync.Once
unblock := func() { once.Do(func() { close(release) }) }
defer unblock()
var closed atomic.Int32
a.OnShutdown(func() error { closed.Add(1); return nil })
a.GET("/work", func(c *httpx.Context) error {
close(started)
<-c.Context().Done()
close(canceled)
<-release
if closed.Load() != 0 {
t.Error("dependency closed during cancellation cleanup")
}
return nil
})
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() { done <- a.ServeContext(ctx, l) }()
client := &http.Client{Timeout: 3 * time.Second, Transport: &http.Transport{TLSClientConfig: tlsClient, ForceAttemptHTTP2: true}}
defer client.CloseIdleConnections()
requestDone := make(chan struct{})
go func() {
defer close(requestDone)
res, err := client.Get("https://" + l.Addr().String() + "/work")
if err == nil {
io.Copy(io.Discard, res.Body)
res.Body.Close()
}
}()
await(t, started)
cancel()
await(t, canceled)
if closed.Load() != 0 {
t.Fatal("dependency closed on cancellation")
}
select {
case err := <-done:
t.Fatal("returned before handler cleanup", err)
default:
}
// Even an in-memory invocation through Engine must now be rejected.
w := httptest.NewRecorder()
a.Engine().ServeHTTP(w, httptest.NewRequest("GET", "/work", nil))
if w.Code != 503 {
t.Fatal(w.Code)
}
unblock()
select {
case err := <-done:
if !errors.Is(err, context.DeadlineExceeded) || errors.Is(err, lifecycle.ErrResourcesRetained) {
t.Fatal(err)
}
case <-time.After(3 * time.Second):
t.Fatal("shutdown stuck")
}
await(t, requestDone)
if closed.Load() != 1 {
t.Fatal("cleanup count", closed.Load())
}
})
}
}
func TestExternalServingEntriesRetainActiveResources(t *testing.T) {
for _, entry := range []string{"App", "Engine", "NewServer", "RunWith"} {
t.Run(entry, func(t *testing.T) {
a := New(config.WithHTTPConfig(config.HTTPConfig{ShutdownCancelTimeout: 30 * time.Millisecond}))
started, release, returned := make(chan struct{}), make(chan struct{}), make(chan struct{})
var closed atomic.Int32
a.OnShutdown(func() error { closed.Add(1); return nil })
a.GET("/", func(*httpx.Context) error {
close(started)
<-release
if closed.Load() != 0 {
t.Error("closed while active")
}
return nil
})
var h http.Handler = a
if entry == "Engine" {
h = a.Engine()
}
if entry == "NewServer" {
h = a.NewServer("").Handler
}
invoke := func(h http.Handler) {
defer close(returned)
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil))
}
defer func() { close(release); await(t, returned) }()
var err error
if entry == "RunWith" {
err = a.RunWith(context.Background(), runnerFunc(func(_ context.Context, h http.Handler, _ string) error { go invoke(h); await(t, started); return nil }))
} else {
go invoke(h)
await(t, started)
err = a.Close()
}
if !errors.Is(err, lifecycle.ErrResourcesRetained) || closed.Load() != 0 {
t.Fatal("retention failed", err, closed.Load())
}
if err = a.Close(); !errors.Is(err, lifecycle.ErrResourcesRetained) {
t.Fatal("repeat close lost retention", err)
}
})
}
}
func TestRequestAdmissionAndCloseRace(t *testing.T) {
for attempt := 0; attempt < 20; attempt++ {
a := New()
var closed atomic.Bool
a.OnShutdown(func() error { closed.Store(true); return nil })
a.GET("/", func(c *httpx.Context) error {
if closed.Load() {
t.Error("handler admitted after resource release")
}
return nil
})
start := make(chan struct{})
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
a.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil))
}()
}
close(start)
if err := a.Close(); err != nil {
t.Fatal(err)
}
wg.Wait()
}
}

View File

@@ -3,14 +3,21 @@ package app
import (
"net/http"
"git.yuntuo.hk/k2safe/go-omni/contract"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/logx"
"git.yuntuo.hk/k2safe/go-omni/response"
)
func (a *App) handle(routes httpx.RouterGroup, method, path string, handler httpx.Handler, middleware []httpx.Middleware) {
routes.Handle(method, path, a.adapt(handler, middleware...))
routes.Handle(method, path, a.bodyHandler(a.defaultBodyLimit(), nil, handler, middleware...))
}
func (a *App) adapt(handler httpx.Handler, middleware ...httpx.Middleware) http.Handler {
return a.adaptResponse(a.responseEncoder, handler, middleware...)
}
func (a *App) adaptResponse(encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) http.Handler {
wrapped := handler
all := make([]httpx.Middleware, 0, len(a.middlewares)+len(middleware))
all = append(all, a.middlewares...)
@@ -21,7 +28,23 @@ func (a *App) adapt(handler httpx.Handler, middleware ...httpx.Middleware) http.
}
}
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if a.config.LanguagePolicy != nil {
request = a.config.LanguagePolicy.Apply(writer, request)
}
request = request.WithContext(logx.WithLogger(contract.WithRequestPolicy(request.Context(), a.config.Request), a.config.LogInstance))
if len(a.config.TrustedProxies) > 0 {
request = request.WithContext(httpx.WithClientIPResolver(request.Context(), a.clientIPResolver))
}
requestContext := httpx.NewContext(request, writer, httpx.RouteParams(request.Context()))
requestContext.WithContext(httpx.WithRequestMetadata(request.Context(), httpx.RequestMetadata{
ClientIP: requestContext.ClientIP(), Method: request.Method, Route: httpx.RoutePattern(request.Context()),
}))
if provider, ok := a.responseEncoder.(response.ErrorRegistryProvider); ok {
requestContext.SetErrorRegistry(provider.ErrorRegistry())
}
requestContext.SetResponseEncoder(encoder)
requestContext.SetErrorLocalizer(a.config.ErrorLocalizer)
defer func() { httpx.CleanupMultipart(requestContext.Request()) }()
if err := wrapped(requestContext); err != nil {
a.writeError(requestContext, err)
}
@@ -34,6 +57,14 @@ func (a *App) Handle(method, path string, handler httpx.Handler, middleware ...h
a.handle(a.router, method, path, handler, middleware)
}
// HandleWithResponse selects the route policy before all application middleware.
func (a *App) HandleWithResponse(method, path string, encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) {
if encoder == nil {
encoder = a.responseEncoder
}
a.router.Handle(method, path, a.bodyHandler(a.defaultBodyLimit(), encoder, handler, middleware...))
}
func (a *App) GET(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
a.handle(a.router, http.MethodGet, path, handler, middleware)
}
@@ -55,18 +86,19 @@ func (a *App) DELETE(path string, handler httpx.Handler, middleware ...httpx.Mid
}
func (a *App) Any(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
a.router.Any(path, a.adapt(handler, middleware...))
a.router.Any(path, a.bodyHandler(a.defaultBodyLimit(), nil, handler, middleware...))
}
// RouterGroup scopes a route prefix and optional middleware.
type RouterGroup struct {
prefix string
group httpx.RouterGroup
app *App
middlewares []httpx.Middleware
}
func (a *App) Group(path string, middleware ...httpx.Middleware) *RouterGroup {
return &RouterGroup{group: a.router.Group(path), app: a, middlewares: middleware}
return &RouterGroup{group: a.router.Group(path), app: a, middlewares: middleware, prefix: joinRoutePath("", path)}
}
func (g *RouterGroup) Use(middleware ...httpx.Middleware) *RouterGroup {
@@ -77,7 +109,7 @@ func (g *RouterGroup) Use(middleware ...httpx.Middleware) *RouterGroup {
func (g *RouterGroup) Group(path string, middleware ...httpx.Middleware) *RouterGroup {
combined := append([]httpx.Middleware{}, g.middlewares...)
combined = append(combined, middleware...)
return &RouterGroup{group: g.group.Group(path), app: g.app, middlewares: combined}
return &RouterGroup{group: g.group.Group(path), app: g.app, middlewares: combined, prefix: joinRoutePath(g.prefix, path)}
}
func (g *RouterGroup) route(method, path string, handler httpx.Handler, middleware []httpx.Middleware) {
@@ -91,6 +123,15 @@ func (g *RouterGroup) Handle(method, path string, handler httpx.Handler, middlew
g.route(method, path, handler, middleware)
}
func (g *RouterGroup) HandleWithResponse(method, path string, encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) {
if encoder == nil {
encoder = g.app.responseEncoder
}
combined := append([]httpx.Middleware{}, g.middlewares...)
combined = append(combined, middleware...)
g.group.Handle(method, path, g.app.bodyHandler(g.app.defaultBodyLimit(), encoder, handler, combined...))
}
func (g *RouterGroup) GET(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
g.route(http.MethodGet, path, handler, middleware)
}
@@ -114,5 +155,5 @@ func (g *RouterGroup) DELETE(path string, handler httpx.Handler, middleware ...h
func (g *RouterGroup) Any(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
combined := append([]httpx.Middleware{}, g.middlewares...)
combined = append(combined, middleware...)
g.group.Any(path, g.app.adapt(handler, combined...))
g.group.Any(path, g.app.bodyHandler(g.app.defaultBodyLimit(), nil, handler, combined...))
}

112
app/transport.go Normal file
View File

@@ -0,0 +1,112 @@
package app
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"strings"
"time"
"git.yuntuo.hk/k2safe/go-omni/config"
"git.yuntuo.hk/k2safe/go-omni/internal/httplimits"
h2transport "git.yuntuo.hk/k2safe/go-omni/transport/http2"
"git.yuntuo.hk/k2safe/go-omni/transport/native"
)
// ServeContext serves a caller-created listener using the configured transport
// and TLS. App takes ownership of the listener, including on startup failure,
// and runs resource cleanup only after admitted handlers have returned.
func (a *App) ServeContext(ctx context.Context, listener net.Listener) (runErr error) {
defer func() { runErr = shutdownResult(ctx, runErr) }()
if listener == nil {
return errors.Join(errors.New("go-omni: nil listener"), a.Close())
}
defer listener.Close()
if ctx == nil {
ctx = context.Background()
}
if ctx.Err() != nil {
_ = listener.Close()
return a.Close()
}
stopWatching := a.watchShutdown(ctx)
defer stopWatching()
serve, err := a.prepareTransport()
if err != nil {
_ = listener.Close()
return errors.Join(err, a.Close())
}
return errors.Join(serve(ctx, listener), a.Close())
}
func (a *App) prepareTransport() (func(context.Context, net.Listener) error, error) {
if err := a.ValidateHTTP(); err != nil {
return nil, err
}
if err := a.config.ValidateTransport(); err != nil {
return nil, err
}
shutdownTimeout := a.config.ShutdownTimeout
if shutdownTimeout <= 0 {
shutdownTimeout = 10 * time.Second
}
var tlsConfig *tls.Config
if strings.TrimSpace(a.config.TLS.CertFile) != "" {
certificate, err := tls.LoadX509KeyPair(a.config.TLS.CertFile, a.config.TLS.KeyFile)
if err != nil {
return nil, fmt.Errorf("go-omni: load TLS certificate: %w", err)
}
tlsConfig = &tls.Config{Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS12}
}
switch a.config.Transport {
case config.TransportNative:
server := native.New(a)
// App enforces the service/route policy after routing. Keep only the
// supported framing ceiling here, never a hidden smaller transport cap.
server.MaxBodyBytes = httplimits.MaxBodyBytes
server.ShutdownTimeout = shutdownTimeout
cfg := a.config.HTTP
if cfg.ReadHeaderTimeout > 0 {
server.ReadHeaderTimeout = cfg.ReadHeaderTimeout
}
if cfg.ReadTimeout > 0 {
server.ReadTimeout = cfg.ReadTimeout
}
if cfg.WriteTimeout > 0 {
server.WriteTimeout = cfg.WriteTimeout
}
if cfg.IdleTimeout > 0 {
server.IdleTimeout = cfg.IdleTimeout
}
if cfg.MaxHeaderBytes > 0 {
server.MaxHeaderBytes = cfg.MaxHeaderBytes
}
if tlsConfig != nil {
return func(ctx context.Context, listener net.Listener) error {
return server.ServeTLS(ctx, listener, tlsConfig)
}, nil
}
return server.Serve, nil
case config.TransportHTTP2:
server := h2transport.New(a)
server.MaxBodyBytes = httplimits.MaxBodyBytes
server.ShutdownTimeout = shutdownTimeout
if a.config.HTTP.IdleTimeout > 0 {
server.IdleTimeout = a.config.HTTP.IdleTimeout
}
if a.config.HTTP.MaxHeaderBytes > 0 {
server.MaxHeaderBytes = a.config.HTTP.MaxHeaderBytes
}
return func(ctx context.Context, listener net.Listener) error {
return server.ServeTLS(ctx, listener, tlsConfig)
}, nil
default:
server := a.NewServer("")
server.TLSConfig = tlsConfig
return func(ctx context.Context, listener net.Listener) error {
return a.serveContext(ctx, server, listener)
}, nil
}
}

331
app/transport_test.go Normal file
View File

@@ -0,0 +1,331 @@
package app
import (
"context"
"errors"
"io"
"net"
"net/http"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"git.yuntuo.hk/k2safe/go-omni/config"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/internal/testutil"
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
)
func TestConfiguredTransportLifecycle(t *testing.T) {
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
for _, timeout := range []bool{false, true} {
name := transport + "/drain"
if timeout {
name = transport + "/timeout"
}
t.Run(name, func(t *testing.T) {
cert, key, tlsClient := testutil.CertificateFiles(t)
limit := 3 * time.Second
if timeout {
limit = 80 * time.Millisecond
}
application := New(config.WithTransport(transport), config.WithTLS(cert, key), config.WithShutdownTimeout(limit))
started, release, canceled := make(chan struct{}), make(chan struct{}), make(chan struct{})
var once sync.Once
unblock := func() { once.Do(func() { close(release) }) }
defer unblock()
var cleaned atomic.Int32
application.OnShutdown(func() error { cleaned.Add(1); return nil })
application.GET("/slow", func(ctx *httpx.Context) error {
close(started)
select {
case <-ctx.Context().Done():
close(canceled)
return ctx.Context().Err()
case <-release:
return ctx.OK("finished")
}
})
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
wrapped := &closingListener{Listener: listener, closed: make(chan struct{})}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
defer listener.Close()
done := make(chan error, 1)
go func() { done <- application.ServeContext(ctx, wrapped) }()
client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{TLSClientConfig: tlsClient, ForceAttemptHTTP2: true}}
defer client.CloseIdleConnections()
requestDone := make(chan error, 1)
go func() {
response, err := client.Get("https://" + listener.Addr().String() + "/slow")
if err == nil {
defer response.Body.Close()
body, readErr := io.ReadAll(response.Body)
err = readErr
if response.StatusCode != 200 || !strings.Contains(string(body), "finished") {
err = errors.New("drained response was lost")
}
}
requestDone <- err
}()
await(t, started)
began := time.Now()
cancel()
await(t, wrapped.closed)
if !timeout {
select {
case <-canceled:
t.Fatal("active request canceled before drain")
default:
}
if cleaned.Load() != 0 {
t.Fatal("cleanup preceded active request")
}
unblock()
}
select {
case err := <-done:
if timeout {
if !errors.Is(err, context.DeadlineExceeded) || time.Since(began) < limit {
t.Fatalf("timeout ignored: %v after %v", err, time.Since(began))
}
await(t, canceled)
} else if err != nil {
t.Fatal(err)
}
case <-time.After(5 * time.Second):
t.Fatal("server failed to stop")
}
select {
case err := <-requestDone:
if !timeout && err != nil {
t.Fatal(err)
}
case <-time.After(5 * time.Second):
t.Fatal("client failed to finish")
}
_ = application.Close()
if cleaned.Load() != 1 {
t.Fatalf("cleanup count %d", cleaned.Load())
}
})
}
}
}
func TestConfiguredStartupFailureClosesResources(t *testing.T) {
occupied, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer occupied.Close()
for _, test := range []struct {
name, errorText string
options []config.Option
}{
{"unknown", "transport", []config.Option{config.WithTransport("unknown")}},
{"http2WithoutTLS", "tls", []config.Option{config.WithTransport("http2")}},
{"partialTLS", "tls", []config.Option{config.WithTLS("missing.crt", "")}},
{"nethttpCertificate", "load TLS certificate", []config.Option{config.WithTLS("missing.crt", "missing.key")}},
{"nativeCertificate", "load TLS certificate", []config.Option{config.WithTransport("native"), config.WithTLS("missing.crt", "missing.key")}},
{"http2Certificate", "load TLS certificate", []config.Option{config.WithTransport("http2"), config.WithTLS("missing.crt", "missing.key")}},
} {
t.Run(test.name, func(t *testing.T) {
application := New(test.options...)
cleanupError := errors.New("cleanup failure")
count := 0
application.OnShutdown(func() error { count++; return cleanupError })
// The occupied address must not mask configuration/certificate errors.
err := application.RunContext(context.Background(), occupied.Addr().String())
if err == nil || !strings.Contains(err.Error(), test.errorText) || !errors.Is(err, cleanupError) {
t.Fatalf("startup: %v", err)
}
_ = application.Close()
if count != 1 {
t.Fatalf("cleanup count %d", count)
}
})
}
for _, canceled := range []bool{false, true} {
application := New(config.WithTransport("unknown"))
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
if canceled {
cancel()
}
err = application.ServeContext(ctx, listener)
cancel()
if canceled && err != nil {
t.Fatal(err)
}
if !canceled && err == nil {
t.Fatal("invalid transport accepted")
}
if _, err := listener.Accept(); !errors.Is(err, net.ErrClosed) {
t.Fatalf("listener leaked: %v", err)
}
}
}
func TestRunContextConfiguredTLS(t *testing.T) {
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
t.Run(transport, func(t *testing.T) {
cert, key, tlsClient := testutil.CertificateFiles(t)
reserved, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
address := reserved.Addr().String()
reserved.Close()
settings := config.Default()
settings.Address, settings.Transport = address, transport
settings.TLS = config.TLSConfig{CertFile: cert, KeyFile: key}
application := New(config.WithConfig(settings))
application.GET("/health", func(ctx *httpx.Context) error { return ctx.OK("ok") })
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- application.RunContext(ctx) }()
defer func() {
cancel()
select {
case err := <-done:
if err != nil {
t.Error(err)
}
case <-time.After(5 * time.Second):
t.Error("RunContext failed to stop")
}
}()
client := &http.Client{Timeout: time.Second, Transport: &http.Transport{TLSClientConfig: tlsClient, ForceAttemptHTTP2: true}}
defer client.CloseIdleConnections()
deadline := time.Now().Add(3 * time.Second)
for {
response, err := client.Get("https://" + address + "/health")
if err == nil {
body, readErr := io.ReadAll(response.Body)
response.Body.Close()
if readErr != nil || response.StatusCode != 200 || !strings.Contains(string(body), `"data":"ok"`) {
t.Fatalf("response %s: %v", body, readErr)
}
want := "h2"
if transport == config.TransportNative {
want = "http/1.1"
}
if response.TLS.NegotiatedProtocol != want || len(response.TLS.VerifiedChains) == 0 {
t.Fatalf("TLS negotiation: %+v", response.TLS)
}
break
}
if time.Now().After(deadline) {
t.Fatal(err)
}
time.Sleep(10 * time.Millisecond)
}
})
}
}
type runnerFunc func(context.Context, http.Handler, string) error
func (f runnerFunc) Run(ctx context.Context, handler http.Handler, address string) error {
return f(ctx, handler, address)
}
func TestRunWithRemainsIndependentOfBuiltInTransport(t *testing.T) {
for _, canceled := range []bool{false, true} {
application := New(config.WithTransport("custom"), config.WithTLS("missing", "missing"))
ctx, cancel := context.WithCancel(context.Background())
if canceled {
cancel()
}
calls, cleanups := 0, 0
application.OnShutdown(func() error { cleanups++; return nil })
err := application.RunWith(ctx, runnerFunc(func(_ context.Context, handler http.Handler, address string) error {
calls++
if handler != application || address != "127.0.0.1:1234" {
t.Fatal("custom runner arguments changed")
}
return nil
}), "127.0.0.1:1234")
cancel()
wantCalls := 1
if canceled {
wantCalls = 0
}
if err != nil || calls != wantCalls || cleanups != 1 {
t.Fatalf("RunWith: %v calls=%d cleanups=%d", err, calls, cleanups)
}
}
}
func TestConfiguredShutdownDoesNotWaitForUncooperativeHandler(t *testing.T) {
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
t.Run(transport, func(t *testing.T) {
cert, key, tlsClient := testutil.CertificateFiles(t)
application := New(config.WithTransport(transport), config.WithTLS(cert, key), config.WithShutdownTimeout(80*time.Millisecond), config.WithHTTPConfig(config.HTTPConfig{ShutdownCancelTimeout: 40 * time.Millisecond}))
var cleaned atomic.Int32
application.OnShutdown(func() error { cleaned.Add(1); return nil })
started := make(chan context.Context, 1)
release, finished := make(chan struct{}), make(chan struct{})
application.GET("/blocked", func(ctx *httpx.Context) error {
defer close(finished)
started <- ctx.Context()
<-release
return nil
})
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() { done <- application.ServeContext(ctx, listener) }()
client := &http.Client{Timeout: 3 * time.Second, Transport: &http.Transport{TLSClientConfig: tlsClient, ForceAttemptHTTP2: true}}
defer client.CloseIdleConnections()
requestDone := make(chan struct{})
go func() {
defer close(requestDone)
response, err := client.Get("https://" + listener.Addr().String() + "/blocked")
if err == nil {
response.Body.Close()
}
}()
var requestContext context.Context
select {
case requestContext = <-started:
case <-time.After(3 * time.Second):
close(release)
t.Fatal("handler did not start")
}
defer func() {
close(release)
await(t, finished)
if err := application.Close(); !errors.Is(err, lifecycle.ErrResourcesRetained) || cleaned.Load() != 0 {
t.Fatalf("late handler return must not trigger cleanup: %v count=%d", err, cleaned.Load())
}
}()
cancel()
select {
case err := <-done:
if !errors.Is(err, context.DeadlineExceeded) || !errors.Is(err, lifecycle.ErrResourcesRetained) || cleaned.Load() != 0 {
t.Fatalf("shutdown: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("handler defeated shutdown deadline")
}
// net/http's HTTP/2 streams observe the closed socket asynchronously.
await(t, requestContext.Done())
await(t, requestDone)
})
}
}

View File

@@ -128,3 +128,34 @@ func TestAppUsesConfiguredAddressWhenNoOverrideIsProvided(t *testing.T) {
t.Fatalf("expected configured address %q, got %q", settings.Address, got)
}
}
func TestAppConfigCopiesTrustedProxies(t *testing.T) {
proxies := []string{"127.0.0.1"}
app := New(WithTrustedProxies(proxies...))
proxies[0] = "10.0.0.0/8"
settings := app.Config()
if len(settings.TrustedProxies) != 1 || settings.TrustedProxies[0] != "127.0.0.1" {
t.Fatalf("trusted proxy config was not copied: %+v", settings.TrustedProxies)
}
settings.TrustedProxies[0] = "192.0.2.0/24"
if app.Config().TrustedProxies[0] != "127.0.0.1" {
t.Fatal("App.Config should return a copy")
}
}
func TestAppTrustedProxyClientIPReachesHandlers(t *testing.T) {
app := New(WithTrustedProxies("127.0.0.1"))
app.GET("/client-ip", func(ctx *Context) error { return ctx.OK(ctx.ClientIP()) })
request := httptest.NewRequest(http.MethodGet, "/client-ip", nil)
request.RemoteAddr = "127.0.0.1:443"
request.Header.Set("X-Forwarded-For", "198.51.100.7")
recorder := httptest.NewRecorder()
app.Engine().ServeHTTP(recorder, request)
var response Response
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.Data != "198.51.100.7" {
t.Fatalf("expected trusted proxy client IP, got %#v", response.Data)
}
}

106
audit/audit.go Normal file
View File

@@ -0,0 +1,106 @@
// Package audit records explicitly selected business events without request bodies.
package audit
import (
"context"
"encoding/json"
"errors"
"git.yuntuo.hk/k2safe/go-omni/logx"
"io"
"sync"
"time"
)
type Event struct {
Time time.Time `json:"time"`
ActorID string `json:"actorId"`
TenantID string `json:"tenantId,omitempty"`
Action string `json:"action"`
Resource string `json:"resource"`
Outcome string `json:"outcome"`
RequestID string `json:"requestId,omitempty"`
TraceID string `json:"traceId,omitempty"`
Fields map[string]string `json:"fields,omitempty"`
}
type Sink interface {
Write(context.Context, Event) error
}
type Recorder struct {
sink Sink
allowed map[string]bool
}
func New(sink Sink, allowedFields ...string) (*Recorder, error) {
if sink == nil || len(allowedFields) > 32 {
return nil, errors.New("audit: sink and at most 32 fields required")
}
r := &Recorder{sink: sink, allowed: map[string]bool{}}
for _, name := range allowedFields {
if name == "" || len(name) > 64 {
return nil, errors.New("audit: invalid field")
}
r.allowed[name] = true
}
return r, nil
}
func (r *Recorder) Record(ctx context.Context, event Event) error {
if ctx == nil {
return errors.New("audit: nil context")
}
if err := ctx.Err(); err != nil {
return err
}
if event.ActorID == "" || event.Action == "" || event.Resource == "" || (event.Outcome != "success" && event.Outcome != "failure" && event.Outcome != "denied") {
return errors.New("audit: actor/action/resource and valid outcome required")
}
for _, value := range []string{event.ActorID, event.TenantID, event.Action, event.Resource} {
if len(value) > 512 {
return errors.New("audit: event field too large")
}
}
event.Time = time.Now().UTC()
event.RequestID = logx.RequestID(ctx)
event.TraceID = logx.TraceID(ctx)
filtered := map[string]string{}
for name, value := range event.Fields {
if r.allowed[name] {
if len(value) > 1024 {
return errors.New("audit: attribute too large")
}
filtered[name] = value
}
}
event.Fields = filtered
return r.sink.Write(ctx, event)
}
// JSONSink borrows its writer and serializes complete JSON lines. Write errors
// are returned; callers choose whether a failed audit must reject an operation.
type JSONSink struct {
mu sync.Mutex
writer io.Writer
}
func NewJSONSink(writer io.Writer) (*JSONSink, error) {
if writer == nil {
return nil, errors.New("audit: writer required")
}
return &JSONSink{writer: writer}, nil
}
func (s *JSONSink) Write(ctx context.Context, event Event) error {
data, err := json.Marshal(event)
if err != nil {
return err
}
data = append(data, '\n')
s.mu.Lock()
defer s.mu.Unlock()
if err := ctx.Err(); err != nil {
return err
}
n, err := s.writer.Write(data)
if err == nil && n != len(data) {
return io.ErrShortWrite
}
return err
}

50
audit/audit_test.go Normal file
View File

@@ -0,0 +1,50 @@
package audit
import (
"bytes"
"context"
"encoding/json"
"errors"
"git.yuntuo.hk/k2safe/go-omni/logx"
"io"
"sync"
"testing"
)
type failedWriter struct{}
func (failedWriter) Write([]byte) (int, error) { return 0, io.ErrClosedPipe }
func TestAllowlistCorrelationAndWriteFailure(t *testing.T) {
var buf bytes.Buffer
sink, _ := NewJSONSink(&buf)
recorder, _ := New(sink, "status")
ctx := logx.WithRequestID(context.Background(), "r1")
ctx = logx.WithTraceID(ctx, "t1")
event := Event{ActorID: "u1", Action: "update", Resource: "object:1", Outcome: "success", Fields: map[string]string{"status": "ok", "password": "secret"}}
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if err := recorder.Record(ctx, event); err != nil {
t.Error(err)
}
}()
}
wg.Wait()
decoder := json.NewDecoder(&buf)
for i := 0; i < 20; i++ {
var got Event
if err := decoder.Decode(&got); err != nil {
t.Fatal(err)
}
if got.Fields["password"] != "" || got.RequestID != "r1" || got.TraceID != "t1" || got.Time.IsZero() {
t.Fatal(got)
}
}
bad, _ := NewJSONSink(failedWriter{})
recorder, _ = New(bad)
if err := recorder.Record(ctx, event); !errors.Is(err, io.ErrClosedPipe) {
t.Fatal(err)
}
}

105
auth/adapters_test.go Normal file
View File

@@ -0,0 +1,105 @@
package auth
import (
"context"
"crypto/ed25519"
"crypto/rand"
"errors"
"git.yuntuo.hk/k2safe/go-omni/cache"
"github.com/golang-jwt/jwt/v5"
"strings"
"testing"
"time"
)
func TestJWTValidationAndRotation(t *testing.T) {
pub, private, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
j, err := NewJWT(JWTConfig{Issuer: "issuer", Audience: "api", Keys: map[string]JWTKey{"first": {Algorithm: "EdDSA", PublicKey: pub}}})
if err != nil {
t.Fatal(err)
}
claims := JWTClaims{RegisteredClaims: jwt.RegisteredClaims{Issuer: "issuer", Subject: "user1", Audience: jwt.ClaimStrings{"api"}, ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour))}, TenantID: "tenant1", Roles: []string{"reader"}}
sign := func(c JWTClaims, kid string, method jwt.SigningMethod, key any) string {
t.Helper()
token := jwt.NewWithClaims(method, c)
token.Header["kid"] = kid
raw, err := token.SignedString(key)
if err != nil {
t.Fatal(err)
}
return raw
}
raw := sign(claims, "first", jwt.SigningMethodEdDSA, private)
// Constructor clones caller-owned key bytes.
pub[0] ^= 1
p, err := j.Authenticate(context.Background(), raw)
if err != nil || p.TenantID != "tenant1" || !p.HasRole("reader") {
t.Fatalf("%+v %v", p, err)
}
for name, change := range map[string]func(*JWTClaims){"issuer": func(c *JWTClaims) { c.Issuer = "bad" }, "audience": func(c *JWTClaims) { c.Audience = jwt.ClaimStrings{"other"} }, "expired": func(c *JWTClaims) { c.ExpiresAt = jwt.NewNumericDate(time.Now().Add(-time.Hour)) }, "noExpiry": func(c *JWTClaims) { c.ExpiresAt = nil }, "future": func(c *JWTClaims) { c.NotBefore = jwt.NewNumericDate(time.Now().Add(time.Hour)) }, "subject": func(c *JWTClaims) { c.Subject = "" }} {
t.Run(name, func(t *testing.T) {
bad := claims
change(&bad)
if _, err = j.Authenticate(context.Background(), sign(bad, "first", jwt.SigningMethodEdDSA, private)); !errors.Is(err, ErrInvalidToken) {
t.Fatal(err)
}
})
}
for _, invalid := range []string{sign(claims, "missing", jwt.SigningMethodEdDSA, private), sign(claims, "first", jwt.SigningMethodHS256, []byte("invalid secret")), strings.Repeat("x", 8193)} {
if _, err = j.Authenticate(context.Background(), invalid); !errors.Is(err, ErrInvalidToken) {
t.Fatal(err)
}
}
if err = j.ReplaceKeys(map[string]JWTKey{"second": {Algorithm: "EdDSA", PublicKey: private.Public()}}); err != nil {
t.Fatal(err)
}
if _, err = j.Authenticate(context.Background(), raw); !errors.Is(err, ErrInvalidToken) {
t.Fatal(err)
}
if _, err = j.Authenticate(context.Background(), sign(claims, "second", jwt.SigningMethodEdDSA, private)); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err = j.Authenticate(ctx, raw); !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
}
func TestSessionsRevokeAndIsolation(t *testing.T) {
mem, _ := cache.NewMemory(cache.MemoryConfig{})
defer mem.Close()
s, err := NewSessions(mem, "session:", time.Minute)
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
token, err := s.Issue(ctx, Principal{UserID: "u1", TenantID: "t1", Roles: []string{"reader"}})
if err != nil {
t.Fatal(err)
}
key, _ := s.key(token)
if strings.Contains(key, token) {
t.Fatal("plaintext session secret stored")
}
p, err := s.Authenticate(ctx, token)
if err != nil || p.UserID != "u1" {
t.Fatal(p, err)
}
p.Roles[0] = "admin"
p, _ = s.Authenticate(ctx, token)
if p.HasRole("admin") {
t.Fatal("principal alias")
}
if err = s.Revoke(ctx, token); err != nil {
t.Fatal(err)
}
if _, err = s.Authenticate(ctx, token); !errors.Is(err, ErrInvalidToken) {
t.Fatal(err)
}
if _, err = s.Authenticate(ctx, "bad"); !errors.Is(err, ErrInvalidToken) {
t.Fatal(err)
}
}

View File

@@ -21,9 +21,10 @@ var (
// Principal is the authenticated identity made available to business handlers.
// Claims are optional provider-specific values and should not be required by core business code.
type Principal struct {
UserID string
Roles []string
Claims map[string]any
UserID string `json:"userId"`
TenantID string `json:"tenantId,omitempty"`
Roles []string `json:"roles,omitempty"`
Claims map[string]any `json:"claims,omitempty"`
}
func (p Principal) HasRole(role string) bool {

127
auth/jwt.go Normal file
View File

@@ -0,0 +1,127 @@
package auth
import (
"context"
"crypto/ed25519"
"crypto/rsa"
"crypto/x509"
"errors"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
)
// JWTKey binds a trusted key ID to exactly one asymmetric signing algorithm.
// Keys come from server configuration, never a token's jku/x5u URL.
type JWTKey struct {
Algorithm string
PublicKey any
}
type JWTConfig struct {
Issuer string `json:"issuer"`
Audience string `json:"audience"`
Leeway time.Duration `json:"leeway"`
Keys map[string]JWTKey `json:"-"`
}
type JWT struct {
config JWTConfig
mu sync.RWMutex
keys map[string]JWTKey
}
type JWTClaims struct {
jwt.RegisteredClaims
TenantID string `json:"tenantId,omitempty"`
Roles []string `json:"roles,omitempty"`
}
func NewJWT(cfg JWTConfig) (*JWT, error) {
if cfg.Issuer == "" || cfg.Audience == "" || cfg.Leeway < 0 || cfg.Leeway > 2*time.Minute {
return nil, errors.New("auth: issuer/audience required, leeway must be 0..2m")
}
j := &JWT{config: cfg}
j.config.Keys = nil
if err := j.ReplaceKeys(cfg.Keys); err != nil {
return nil, err
}
return j, nil
}
// ReplaceKeys atomically rotates the trusted key set. Retain old keys until
// their tokens expire if a rotation should preserve existing sessions.
func (j *JWT) ReplaceKeys(keys map[string]JWTKey) error {
if len(keys) == 0 || len(keys) > 64 {
return errors.New("auth: require 1..64 JWT keys")
}
copyKeys := make(map[string]JWTKey, len(keys))
for id, key := range keys {
if id == "" || len(id) > 128 {
return errors.New("auth: invalid key ID")
}
switch key.Algorithm {
case "RS256":
k, ok := key.PublicKey.(*rsa.PublicKey)
if !ok || k == nil || k.N == nil || k.N.BitLen() < 2048 || k.E < 3 {
return errors.New("auth: RS256 requires a >=2048-bit RSA public key")
}
case "EdDSA":
k, ok := key.PublicKey.(ed25519.PublicKey)
if !ok || len(k) != ed25519.PublicKeySize {
return errors.New("auth: EdDSA requires an Ed25519 public key")
}
default:
return errors.New("auth: supported algorithms are RS256 and EdDSA")
}
encoded, err := x509.MarshalPKIXPublicKey(key.PublicKey)
if err != nil {
return err
}
key.PublicKey, err = x509.ParsePKIXPublicKey(encoded)
if err != nil {
return err
}
copyKeys[id] = key
}
j.mu.Lock()
j.keys = copyKeys
j.mu.Unlock()
return nil
}
func (j *JWT) Authenticate(ctx context.Context, raw string) (Principal, error) {
if ctx == nil {
return Principal{}, ErrInvalidToken
}
if err := ctx.Err(); err != nil {
return Principal{}, err
}
if len(raw) == 0 || len(raw) > 8192 {
return Principal{}, ErrInvalidToken
}
claims := new(JWTClaims)
token, err := jwt.ParseWithClaims(raw, claims, func(t *jwt.Token) (any, error) {
id, ok := t.Header["kid"].(string)
if !ok {
return nil, ErrInvalidToken
}
j.mu.RLock()
key, exists := j.keys[id]
j.mu.RUnlock()
if !exists || t.Method.Alg() != key.Algorithm {
return nil, ErrInvalidToken
}
return key.PublicKey, nil
}, jwt.WithValidMethods([]string{"RS256", "EdDSA"}), jwt.WithIssuer(j.config.Issuer), jwt.WithAudience(j.config.Audience), jwt.WithExpirationRequired(), jwt.WithIssuedAt(), jwt.WithLeeway(j.config.Leeway))
if err != nil || !token.Valid || claims.Subject == "" || len(claims.Subject) > 256 || len(claims.TenantID) > 256 || len(claims.Roles) > 64 {
return Principal{}, ErrInvalidToken
}
for _, role := range claims.Roles {
if role == "" || len(role) > 128 {
return Principal{}, ErrInvalidToken
}
}
if err := ctx.Err(); err != nil {
return Principal{}, err
}
return Principal{UserID: claims.Subject, TenantID: claims.TenantID, Roles: append([]string(nil), claims.Roles...)}, nil
}

34
auth/policy.go Normal file
View File

@@ -0,0 +1,34 @@
package auth
import (
"context"
"errors"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
)
type Policy interface {
Authorize(context.Context, Principal, string, string) error
}
type PolicyFunc func(context.Context, Principal, string, string) error
func (f PolicyFunc) Authorize(ctx context.Context, p Principal, action, resource string) error {
return f(ctx, p, action, resource)
}
func RequirePermission(policy Policy, action string, resource func(*httpx.Context) string) httpx.Middleware {
return func(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error {
p, ok := FromContext(c)
if !ok {
return apierrors.Unauthorized("UNAUTHENTICATED", "authentication required")
}
if policy == nil || action == "" || resource == nil {
return apierrors.Internal(errors.New("auth: incomplete permission policy"))
}
if err := policy.Authorize(c.Context(), p, action, resource(c)); err != nil {
return apierrors.Forbidden("FORBIDDEN", "permission denied").WithCause(err)
}
return next(c)
}
}
}

87
auth/session.go Normal file
View File

@@ -0,0 +1,87 @@
package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"time"
"git.yuntuo.hk/k2safe/go-omni/cache"
)
// Sessions stores server-issued opaque bearer credentials as SHA-256 keys.
// The caller verifies login credentials before Issue; Store owns persistence.
type Sessions struct {
store cache.Store
prefix string
ttl time.Duration
}
func NewSessions(store cache.Store, prefix string, ttl time.Duration) (*Sessions, error) {
if store == nil || len(prefix) > 512 || ttl < time.Second || ttl > 30*24*time.Hour {
return nil, errors.New("auth: invalid session configuration")
}
return &Sessions{store: store, prefix: prefix, ttl: ttl}, nil
}
func (s *Sessions) Issue(ctx context.Context, p Principal) (string, error) {
if p.UserID == "" || len(p.UserID) > 256 || len(p.TenantID) > 256 {
return "", ErrInvalidToken
}
data, err := json.Marshal(p)
if err != nil {
return "", err
}
if len(data) > 16384 {
return "", errors.New("auth: session principal too large")
}
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
return "", err
}
token := base64.RawURLEncoding.EncodeToString(secret)
key, _ := s.key(token)
if err := s.store.Set(ctx, key, data, s.ttl); err != nil {
return "", err
}
return token, nil
}
func (s *Sessions) key(token string) (string, error) {
if len(token) != 43 {
return "", ErrInvalidToken
}
b, err := base64.RawURLEncoding.DecodeString(token)
if err != nil || len(b) != 32 {
return "", ErrInvalidToken
}
sum := sha256.Sum256(b)
return s.prefix + hex.EncodeToString(sum[:]), nil
}
func (s *Sessions) Authenticate(ctx context.Context, token string) (Principal, error) {
key, err := s.key(token)
if err != nil {
return Principal{}, err
}
data, err := s.store.Get(ctx, key)
if errors.Is(err, cache.ErrMiss) {
return Principal{}, ErrInvalidToken
}
if err != nil {
return Principal{}, err
}
var p Principal
if len(data) > 16384 || json.Unmarshal(data, &p) != nil || p.UserID == "" {
return Principal{}, ErrInvalidToken
}
return p, nil
}
func (s *Sessions) Revoke(ctx context.Context, token string) error {
key, err := s.key(token)
if err != nil {
return err
}
return s.store.Delete(ctx, key)
}

135
cache/cache.go vendored Normal file
View File

@@ -0,0 +1,135 @@
// Package cache provides replaceable byte-oriented caches and bounded local
// request coalescing. Storage errors are never silently treated as misses.
package cache
import (
"context"
"errors"
"fmt"
"sync"
"time"
)
var (
ErrMiss = errors.New("cache: key not found")
ErrClosed = errors.New("cache: closed")
ErrTooLarge = errors.New("cache: entry exceeds capacity")
ErrBusy = errors.New("cache: too many concurrent loads")
)
// Store implementations copy values and accept ttl=0 for no expiration.
// Negative TTLs are invalid. Delete is successful for missing keys.
type Store interface {
Get(context.Context, string) ([]byte, error)
Set(context.Context, string, []byte, time.Duration) error
Delete(context.Context, ...string) error
}
func Check(ctx context.Context, key string, ttl time.Duration) error {
if ctx == nil {
return errors.New("cache: nil context")
}
if err := ctx.Err(); err != nil {
return err
}
if key == "" || len(key) > 1024 {
return errors.New("cache: key must contain 1..1024 bytes")
}
if ttl < 0 {
return errors.New("cache: negative TTL")
}
return nil
}
type LoaderConfig struct {
MaxLoads int `json:"maxLoads"`
Timeout time.Duration `json:"timeout"`
}
type flight struct {
done chan struct{}
value []byte
err error
}
// Loader coalesces concurrent misses for the same key within this process.
// Caller cancellation stops waiting without canceling other callers' load.
// Load functions must observe their context, which has a bounded deadline.
type Loader struct {
store Store
config LoaderConfig
mu sync.Mutex
flights map[string]*flight
}
func NewLoader(store Store, cfg LoaderConfig) (*Loader, error) {
if store == nil || cfg.MaxLoads < 0 || cfg.Timeout < 0 {
return nil, errors.New("cache: invalid loader configuration")
}
if cfg.MaxLoads == 0 {
cfg.MaxLoads = 128
}
if cfg.Timeout == 0 {
cfg.Timeout = 5 * time.Second
}
return &Loader{store: store, config: cfg, flights: make(map[string]*flight)}, nil
}
func (l *Loader) GetOrLoad(ctx context.Context, key string, ttl time.Duration, load func(context.Context) ([]byte, error)) ([]byte, error) {
if err := Check(ctx, key, ttl); err != nil {
return nil, err
}
if load == nil {
return nil, errors.New("cache: nil loader")
}
value, err := l.store.Get(ctx, key)
if !errors.Is(err, ErrMiss) {
return value, err
}
l.mu.Lock()
f := l.flights[key]
if f == nil {
if len(l.flights) >= l.config.MaxLoads {
l.mu.Unlock()
return nil, ErrBusy
}
f = &flight{done: make(chan struct{})}
l.flights[key] = f
go l.run(context.WithoutCancel(ctx), key, ttl, load, f)
}
l.mu.Unlock()
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-f.done:
return append([]byte(nil), f.value...), f.err
}
}
func (l *Loader) run(parent context.Context, key string, ttl time.Duration, load func(context.Context) ([]byte, error), f *flight) {
ctx, cancel := context.WithTimeout(parent, l.config.Timeout)
defer cancel()
defer func() {
if value := recover(); value != nil {
f.err = fmt.Errorf("cache: loader panic: %v", value)
}
l.mu.Lock()
delete(l.flights, key)
close(f.done)
l.mu.Unlock()
}()
// Another request may have populated the key between the first Get and
// flight acquisition. Recheck inside the coalesced load.
f.value, f.err = l.store.Get(ctx, key)
if !errors.Is(f.err, ErrMiss) {
return
}
f.value, f.err = load(ctx)
if f.err == nil {
f.err = ctx.Err()
}
if f.err == nil {
f.err = l.store.Set(ctx, key, f.value, ttl)
}
f.value = append([]byte(nil), f.value...)
}

118
cache/cache_test.go vendored Normal file
View File

@@ -0,0 +1,118 @@
package cache
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
)
func TestMemoryOwnershipCapacityExpiryAndClose(t *testing.T) {
m, err := NewMemory(MemoryConfig{MaxEntries: 2, MaxBytes: 10})
if err != nil {
t.Fatal(err)
}
now := time.Now()
m.now = func() time.Time { return now }
ctx := context.Background()
value := []byte("aa")
if err := m.Set(ctx, "a", value, time.Second); err != nil {
t.Fatal(err)
}
value[0] = 'x'
got, _ := m.Get(ctx, "a")
if string(got) != "aa" {
t.Fatal("retained caller memory")
}
got[0] = 'z'
got, _ = m.Get(ctx, "a")
if string(got) != "aa" {
t.Fatal("returned mutable cache memory")
}
m.Set(ctx, "b", []byte("bb"), 0)
m.Get(ctx, "a")
m.Set(ctx, "c", []byte("cc"), 0)
if _, err := m.Get(ctx, "b"); !errors.Is(err, ErrMiss) {
t.Fatal("LRU eviction failed")
}
now = now.Add(time.Second)
if _, err := m.Get(ctx, "a"); !errors.Is(err, ErrMiss) {
t.Fatal("expiration boundary ignored")
}
if err := m.Set(ctx, "large", []byte("123456"), 0); !errors.Is(err, ErrTooLarge) {
t.Fatal(err)
}
if _, err := m.Get(ctx, "c"); err != nil {
t.Fatal("oversized write evicted valid data")
}
canceled, cancel := context.WithCancel(ctx)
cancel()
if err := m.Delete(canceled, "c"); !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
m.Close()
m.Close()
if _, err := m.Get(ctx, "c"); !errors.Is(err, ErrClosed) {
t.Fatal(err)
}
}
func TestLoaderCoalescesAndSeparatesCallerCancellation(t *testing.T) {
m, _ := NewMemory(MemoryConfig{})
loader, _ := NewLoader(m, LoaderConfig{MaxLoads: 1, Timeout: time.Second})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
started, release := make(chan struct{}), make(chan struct{})
var calls atomic.Int32
load := func(ctx context.Context) ([]byte, error) {
calls.Add(1)
close(started)
select {
case <-release:
return []byte("value"), nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
first := make(chan error, 1)
go func() { _, err := loader.GetOrLoad(ctx, "key", time.Minute, load); first <- err }()
<-started
second := make(chan error, 1)
go func() {
v, err := loader.GetOrLoad(context.Background(), "key", time.Minute, load)
if err == nil && string(v) != "value" {
err = errors.New("wrong value")
}
second <- err
}()
if _, err := loader.GetOrLoad(context.Background(), "other", 0, load); !errors.Is(err, ErrBusy) {
t.Fatal(err)
}
cancel()
if err := <-first; !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
close(release)
if err := <-second; err != nil {
t.Fatal(err)
}
if calls.Load() != 1 {
t.Fatalf("loads=%d", calls.Load())
}
}
func TestLoaderPanicDeadlineAndStoreFailure(t *testing.T) {
m, _ := NewMemory(MemoryConfig{})
l, _ := NewLoader(m, LoaderConfig{Timeout: 20 * time.Millisecond})
if _, err := l.GetOrLoad(context.Background(), "panic", 0, func(context.Context) ([]byte, error) { panic("failure") }); err == nil {
t.Fatal("panic escaped")
}
if _, err := l.GetOrLoad(context.Background(), "panic", 0, func(ctx context.Context) ([]byte, error) { <-ctx.Done(); return nil, ctx.Err() }); !errors.Is(err, context.DeadlineExceeded) {
t.Fatal(err)
}
m.Close()
if _, err := l.GetOrLoad(context.Background(), "closed", 0, func(context.Context) ([]byte, error) { t.Fatal("store outage triggered load"); return nil, nil }); !errors.Is(err, ErrClosed) {
t.Fatal(err)
}
}

135
cache/memory.go vendored Normal file
View File

@@ -0,0 +1,135 @@
package cache
import (
"container/list"
"context"
"errors"
"sync"
"time"
)
type MemoryConfig struct {
MaxEntries int `json:"maxEntries"`
MaxBytes int `json:"maxBytes"` // Sum of key and value bytes, excluding Go object overhead.
}
type entry struct {
key string
value []byte
expires time.Time
}
// Memory is an LRU cache with lazy expiration and no background goroutines.
type Memory struct {
mu sync.Mutex
config MemoryConfig
items map[string]*list.Element
lru *list.List
bytes int
closed bool
now func() time.Time
}
func NewMemory(cfg MemoryConfig) (*Memory, error) {
if cfg.MaxEntries < 0 || cfg.MaxBytes < 0 {
return nil, errors.New("cache: negative capacity")
}
if cfg.MaxEntries == 0 {
cfg.MaxEntries = 4096
}
if cfg.MaxBytes == 0 {
cfg.MaxBytes = 32 << 20
}
return &Memory{config: cfg, items: make(map[string]*list.Element), lru: list.New(), now: time.Now}, nil
}
func (m *Memory) Get(ctx context.Context, key string) ([]byte, error) {
if err := Check(ctx, key, 0); err != nil {
return nil, err
}
m.mu.Lock()
defer m.mu.Unlock()
if m.closed {
return nil, ErrClosed
}
e := m.items[key]
if e == nil {
return nil, ErrMiss
}
item := e.Value.(*entry)
if !item.expires.IsZero() && !m.now().Before(item.expires) {
m.remove(e)
return nil, ErrMiss
}
m.lru.MoveToFront(e)
return append([]byte(nil), item.value...), nil
}
func (m *Memory) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
if err := Check(ctx, key, ttl); err != nil {
return err
}
if len(value) > m.config.MaxBytes-len(key) {
return ErrTooLarge
}
m.mu.Lock()
defer m.mu.Unlock()
if m.closed {
return ErrClosed
}
if e := m.items[key]; e != nil {
m.remove(e)
}
for len(m.items) >= m.config.MaxEntries || m.bytes+len(key)+len(value) > m.config.MaxBytes {
m.remove(m.lru.Back())
}
item := &entry{key: key, value: append([]byte(nil), value...)}
if ttl > 0 {
item.expires = m.now().Add(ttl)
}
m.items[key] = m.lru.PushFront(item)
m.bytes += len(key) + len(value)
return nil
}
func (m *Memory) Delete(ctx context.Context, keys ...string) error {
if ctx == nil {
return errors.New("cache: nil context")
}
if err := ctx.Err(); err != nil {
return err
}
for _, key := range keys {
if err := Check(ctx, key, 0); err != nil {
return err
}
}
m.mu.Lock()
defer m.mu.Unlock()
if m.closed {
return ErrClosed
}
for _, key := range keys {
if e := m.items[key]; e != nil {
m.remove(e)
}
}
return nil
}
func (m *Memory) remove(e *list.Element) {
item := e.Value.(*entry)
m.bytes -= len(item.key) + len(item.value)
delete(m.items, item.key)
m.lru.Remove(e)
}
func (m *Memory) Close() error {
m.mu.Lock()
defer m.mu.Unlock()
m.closed = true
clear(m.items)
m.lru.Init()
m.bytes = 0
return nil
}

68
cache/redis/redis.go vendored Normal file
View File

@@ -0,0 +1,68 @@
// Package redis adapts go-redis's pooled client to cache.Store.
package redis
import (
"context"
"errors"
"strings"
"time"
"git.yuntuo.hk/k2safe/go-omni/cache"
goredis "github.com/redis/go-redis/v9"
)
// Store borrows the client; its owner must close the client after users stop.
type Store struct {
client goredis.Cmdable
prefix string
}
func New(client goredis.Cmdable, prefix string) (*Store, error) {
if client == nil || len(prefix) > 1024 || strings.ContainsAny(prefix, "\r\n") {
return nil, errors.New("cache/redis: invalid client or prefix")
}
return &Store{client: client, prefix: prefix}, nil
}
func (s *Store) Get(ctx context.Context, key string) ([]byte, error) {
if err := cache.Check(ctx, key, 0); err != nil {
return nil, err
}
value, err := s.client.Get(ctx, s.prefix+key).Bytes()
if errors.Is(err, goredis.Nil) {
return nil, cache.ErrMiss
}
return value, err
}
func (s *Store) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
if err := cache.Check(ctx, key, ttl); err != nil {
return err
}
// Redis expiration precision is milliseconds; round up, never truncate a
// positive duration to an unbounded lifetime.
if ttl > 0 && ttl < time.Millisecond {
ttl = time.Millisecond
}
return s.client.Set(ctx, s.prefix+key, value, ttl).Err()
}
func (s *Store) Delete(ctx context.Context, keys ...string) error {
if ctx == nil {
return errors.New("cache/redis: nil context")
}
if err := ctx.Err(); err != nil {
return err
}
names := make([]string, len(keys))
for i, key := range keys {
if err := cache.Check(ctx, key, 0); err != nil {
return err
}
names[i] = s.prefix + key
}
if len(names) == 0 {
return nil
}
return s.client.Del(ctx, names...).Err()
}

125
cache/redis/redis_test.go vendored Normal file
View File

@@ -0,0 +1,125 @@
package redis
import (
"context"
"errors"
"fmt"
"os"
"testing"
"time"
"git.yuntuo.hk/k2safe/go-omni/cache"
goredis "github.com/redis/go-redis/v9"
)
type fakeClient struct {
goredis.Cmdable
key string
ttl time.Duration
failure error
}
func (f *fakeClient) Get(ctx context.Context, key string) *goredis.StringCmd {
f.key = key
return goredis.NewStringResult("", f.failure)
}
func (f *fakeClient) Set(ctx context.Context, key string, value interface{}, ttl time.Duration) *goredis.StatusCmd {
f.key, f.ttl = key, ttl
return goredis.NewStatusResult("OK", f.failure)
}
func (f *fakeClient) Del(ctx context.Context, keys ...string) *goredis.IntCmd {
f.key = keys[0]
return goredis.NewIntResult(1, f.failure)
}
func TestAdapterPreservesErrorsAndExpiration(t *testing.T) {
f := &fakeClient{failure: goredis.Nil}
store, _ := New(f, "app:")
if _, err := store.Get(context.Background(), "key"); !errors.Is(err, cache.ErrMiss) || f.key != "app:key" {
t.Fatal(err, f.key)
}
f.failure = errors.New("unavailable")
if _, err := store.Get(context.Background(), "key"); !errors.Is(err, f.failure) {
t.Fatal(err)
}
f.failure = nil
if err := store.Set(context.Background(), "key", []byte("value"), time.Nanosecond); err != nil || f.ttl != time.Millisecond {
t.Fatal(err, f.ttl)
}
if err := store.Set(context.Background(), "key", nil, -time.Second); err == nil {
t.Fatal("negative TTL accepted")
}
if err := store.Delete(context.Background(), "key"); err != nil || f.key != "app:key" {
t.Fatal(err)
}
}
func TestRedisIntegration(t *testing.T) {
address := os.Getenv("GO_OMNI_REDIS_TEST_ADDR")
if address == "" {
t.Skip("set GO_OMNI_REDIS_TEST_ADDR to an isolated test Redis")
}
client := goredis.NewClient(&goredis.Options{Addr: address, PoolSize: 2, MaxRetries: -1, ContextTimeoutEnabled: true, DialTimeout: time.Second, ReadTimeout: time.Second, WriteTimeout: time.Second})
defer client.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := client.Ping(ctx).Err(); err != nil {
t.Fatal(err)
}
prefix := fmt.Sprintf("goOmniTest:%d:", time.Now().UnixNano())
store, _ := New(client, prefix)
defer store.Delete(context.Background(), "key", "forever", "list", "canceled")
if err := store.Set(ctx, "key", []byte{0, 1, 255}, 80*time.Millisecond); err != nil {
t.Fatal(err)
}
v, err := store.Get(ctx, "key")
if err != nil || len(v) != 3 || v[2] != 255 {
t.Fatal(v, err)
}
deadline := time.Now().Add(time.Second)
for {
_, err := store.Get(ctx, "key")
if errors.Is(err, cache.ErrMiss) {
break
}
if err != nil || time.Now().After(deadline) {
t.Fatal("TTL failed", err)
}
time.Sleep(5 * time.Millisecond)
}
if err := store.Set(ctx, "forever", []byte("ok"), 0); err != nil {
t.Fatal(err)
}
if ttl := client.PTTL(ctx, prefix+"forever").Val(); ttl != -1 {
t.Fatalf("unexpected persistent TTL %v", ttl)
}
if err := store.Delete(ctx, "forever"); err != nil {
t.Fatal(err)
}
if _, err := store.Get(ctx, "forever"); !errors.Is(err, cache.ErrMiss) {
t.Fatal(err)
}
if err := client.RPush(ctx, prefix+"list", "value").Err(); err != nil {
t.Fatal(err)
}
if _, err := store.Get(ctx, "list"); err == nil || errors.Is(err, cache.ErrMiss) {
t.Fatal("Redis error treated as miss", err)
}
canceled, stop := context.WithCancel(ctx)
stop()
if err := store.Set(canceled, "canceled", []byte("value"), 0); !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
if _, err := store.Get(ctx, "canceled"); !errors.Is(err, cache.ErrMiss) {
t.Fatal("canceled write reached Redis", err)
}
if err := store.Delete(ctx, "list"); err != nil {
t.Fatal(err)
}
if err := client.Close(); err != nil {
t.Fatal(err)
}
if _, err := store.Get(ctx, "key"); err == nil || errors.Is(err, cache.ErrMiss) {
t.Fatal("closed client treated as miss", err)
}
}

View File

@@ -1,67 +1,83 @@
# go-omni CLI
从远程安装,生成一个带 Handler / Service / Repository / Model 分层的服务骨架:
v0.28.1 的 `workspace generate/check` 支持 API 与通用工程混合处理。每个 services 项可指定 `kind: api | project`,省略时按工程清单识别,旧 API 配置继续有效。format 只处理 API 契约,通用工程显示 SKIP;check 只读并汇总各工程失败。见 [可运行示例](../../examples/mixedWorkspace/README.md)。
v0.28 新增 `project new/generate/check`:生成无 API、无监听端口和无外部服务依赖的通用 Go 程序,配置/日志/生命周期自动装配,项目维护 svc 与 Run。`project generate --dry-run` 预览,`project check` 只读检查;详见 [通用工程指南](../../PROGRAM.md)。
v0.26 新增 `i18n check|export|types|extract`:检查独立语言资源、提取 Go 静态消息引用或已导出的错误目录,导出指定命名空间的前端包和 TypeScript 类型。输入只读,结果写 stdout;动态引用明确报告。完整命令见 [国际化工具链](../../i18n/README.md#cli-提取检查与导出)。`.api errorCatalog` 可声明 translation 引用,旧 runtime 在生成前拒绝,接入时使用当前同版 CLI 与框架。
v0.19 移除结构映射生成:`omni.gen.json` 只保留 Repository 和中间件选项。普通接口直接使用生成 DTO;需要类型转换时用普通 Go 或可选 [copyx](../../COPY.md)。旧 `mappings` / `mappingFiles` 必须按迁移步骤清理,CLI 不会静默删除仍在调用的旧生成文件。
公共字段组合、中间件工厂和按需 Repository 继续支持,见 [生成能力指南](../../GENERATION.md)。
v0.16 增加基础类型/动态 JSON 数组返回、OpenAPI 公共引用和 workspace Go Catalog 统一导出。见 [接入与筛选规则](../../DEVELOPMENT.md#同一份-go-错误目录供多个服务生成)。
v0.14 新增 `api format [--check]`、`api upgrade [--dry-run|--preview <空目录>|--apply]`,以及 `workspace format|generate|check --file omni.workspace.json`。详见 [通用开发指南](../../DEVELOPMENT.md)。
API 新项目采用根目录 `main.go` 与 `internal/config、svc、logic、handler、types`。完整职责、启动扩展、流式接口和生成文件边界见 [项目指南](../../PROJECT.md)。
## 创建 API 项目
```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.5.0
go-omni new --module example.com/billing ./billing
cd billing
go mod tidy
go run ./cmd/server
# 仓库读取权限与 PATH 配置见根目录 README
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.1-preview
go-omni new --module example.com/hello ./hello
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
go-omni new --api service.api --module example.com/example ./example
```
通过 `go install ...@版本` 安装的 CLI 从构建信息中读取版本,并把它固定到生成项目的
`go.mod`。不会使用不存在的 `v0.0.0`,也不会在普通业务目录中添加本地 `replace`。
`new` 与 `api new` 统一生成契约项目;不再提供另一套 CRUD 启动目录。未实现的 Logic 返回 501;中间件工厂未实现时阻止启动,既有 handler 模式仍返回 501。进入项目执行 `go mod tidy`、`go run .`、`go build .`。
需要选择其他已发布版本时,使用完整版本号:
参数:`--database none|memory|mysql`(默认 none;memory 等同 none),`--storage none|local|s3|r2|oss`,`--log-output stdout|stderr|file`,`--log-format json|text`。基础设施按需生成,连接凭据由环境提供,不自动创建数据库、表或云资源。选择 MySQL 不会凭空生成业务表或 Repository 查询。
存储配置和客户端位于 config/svc,业务使用统一接口。local 存储签名端点由生成器接入;上传、下载等业务路由由 `.api` 定义。认证与存储路径由项目实现。
## 日常生成
```sh
go-omni new --framework-version v0.5.0 --module example.com/billing ./billing
go-omni api validate
go-omni api inspect
go-omni api generate --dry-run
go-omni api generate
go-omni api generate --check
go-omni api export > openapi.json
go test ./...
```
`--framework-version` 禁用自动本地替换;`main`、`latest` 等浮动引用不接受为固定版本。
源码构建的 CLI 使用代码中声明的目标发布版本;该版本未发布时请显式选择已发布版本,或用 `--framework-dir` 验证本地源码。
项目内自动发现 manifest 中的 API 入口,也可显式 `--api ... --dir ...`。`api go -api ... -dir ...` 为命令别名,不代表兼容全部 goctl 语法。
在 GoOmni 源码目录执行时,命令会自动在生成项目的 `go.mod` 中加入本地
`replace git.yuntuo.hk/k2safe/go-omni => ...`,便于立即运行和测试。目标目录非空时命令会停止,明确传入
`--force` 才会覆盖脚手架文件。
main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节保留。手改生成文件会报冲突,`--check` 检查入口与所有生成产物。没有 `--force` 覆盖业务的入口。初始基础设施 profile 在重新生成时沿用,后续项目依赖由用户修改 config/svc。
也可以在任意目录显式指定源码路径:
生成/预览会比较上次契约快照,提示受影响的 Logic 文件、DTO 字段和 body/流式模式变化。明确的 Logic 签名错误显示文件行号、原签名和期望签名;生成后未调整业务时,`--check` 仍返回失败。REVIEW 不强制确认,类型别名/build tags 等不确定情况交给 `go test ./...` 验证。完整行为见 [契约变更提示](../../PROJECT.md#契约变更提示)。
`@app(defaultResponse: TemplateName)` 选择 App 默认响应,路由/分组可以覆盖。错误表可以只在 Go 中维护,通过 svc.AppOptions 注册一次。上传和原始 body 的自动 Logic 参数、下载输出见 [项目指南](../../PROJECT.md)。
## 版本与迁移
CLI 与框架依赖固定使用 v0.28.1-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
```sh
go-omni new --framework-dir /path/to/go-omni --module example.com/billing ./billing
# 在 go-omni 源码仓库中执行
go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello
```
默认生成内存存储版本;选择 MySQL 时:
当前模板与依赖以 v0.28.1-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
## 数据库迁移命令
保留独立迁移工具,应用启动不自动迁移:
```sh
go-omni new --database mysql --module example.com/billing ./billing
go-omni migrate --dir migrations
# 检查预览,配置 GO_OMNI_DB_DSN 后显式应用
go-omni migrate --dir migrations --apply
```
MySQL 项目额外包含 `schema.sql`、MySQL Repository 和可选的数据库集成测试。
先应用建表脚本,再设置 `GO_OMNI_DB_DSN` 启动。ServiceContext 负责连接池初始化、表结构检查、
就绪探针注册和关闭。两种模式都提供用户 CRUD、分页、输入校验和统一错误响应。
SQL 命名示例 `0001_createUsers.up.sql`;预览只显示版本和 SHA-256,不连接数据库。失败保留 dirty 状态,不自动 down/force。细节见 [扩展模块](../../ADVANCED.md)。
生成服务默认包含 `/health/live`、`/health/ready` 和示例 `/users` CRUD。
退出信号通过 `signal.NotifyContext` 传入 `RunContext`,Logger 的关闭函数注册到应用生命周期。
### v0.22 文件显式配置
发布验收使用 Python 3 标准库脚本,不需要额外 Python 依赖:
新生成 `internal/config/loadGen.go` 调用统一加载,纳入生成检查;项目 config.go 只维护字段、默认值和校验,loader.go 用于注册额外格式。JSON/YAML/XML 内置。旧手写 Load 会阻止生成,请先按 [迁移步骤](../../CONFIG.md#v022-迁移旧模式已移除) 整理,不会自动覆盖业务代码。
```sh
python3 scripts/verifyScaffold.py --version v0.5.0
python3 scripts/verifyDatabase.py --version v0.5.0
```
脚本在源码目录外安装远程 CLI、生成项目,确认固定版本且没有 `replace`,再下载依赖、
编译、请求接口,并对真实二进制分别发送 `SIGINT` 和 `SIGTERM`。同时验证框架 `Run` 的
信号处理和清理回调。每个进程都必须退出码为 0、释放监听端口;临时项目和测试进程会被清理。
发布前验证当前源码:
```sh
go build -o /tmp/go-omni ./cmd/go-omni
python3 scripts/verifyScaffold.py --cli /tmp/go-omni --framework-dir .
python3 scripts/verifyDatabase.py --cli /tmp/go-omni --framework-dir .
```
数据库验收需要本地 Docker,使用独立 MySQL 8.4 容器;不会连接已有业务数据库,结束后清理测试容器与数据卷。
`go run . -config etc/config.test.yaml -check-config` 只做配置解析和校验,不初始化日志、数据库或监听。文件中的固定值不受环境自动覆盖;`${NAME}` 为必填引用,`${NAME:-fallback}` 提供备用值。

211
cmd/go-omni/api.go Normal file
View File

@@ -0,0 +1,211 @@
package main
import (
"fmt"
"os"
"strings"
"text/tabwriter"
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/apigen"
)
func runAPI(args []string) error {
if len(args) == 0 || args[0] == "help" || args[0] == "--help" || args[0] == "-h" {
usage()
return nil
}
command := args[0]
if command == "upgrade" {
return runUpgrade(args[1:])
}
if command == "go" {
command = "generate"
}
if command == "check" {
command = "validate"
}
if command != "new" && command != "generate" && command != "validate" && command != "inspect" && command != "export" && command != "format" {
return fmt.Errorf("unknown api command %q", command)
}
readOnly := command == "validate" || command == "inspect" || command == "export" || command == "format"
o := apigen.Options{}
for i := 1; i < len(args); i++ {
arg := args[i]
if arg == "--help" || arg == "-h" {
usage()
return nil
}
if arg == "--check" || arg == "--dry-run" {
if command != "generate" && !(command == "format" && arg == "--check") {
return fmt.Errorf("%s belongs to api generate", arg)
}
if arg == "--check" {
o.Check = true
} else {
o.DryRun = true
}
continue
}
if !strings.HasPrefix(arg, "-") {
if command == "new" && o.Dir == "" {
o.Dir = arg
continue
}
return fmt.Errorf("unexpected argument %q", arg)
}
name, value, inline := strings.Cut(arg, "=")
// goctl-style -api/-dir aliases ease migration; long forms are canonical.
if name == "-api" {
name = "--api"
}
if name == "-dir" {
name = "--dir"
}
switch name {
case "--api", "--dir", "--module", "--framework-dir", "--framework-version", "--log-output", "--log-format", "--database", "--storage":
default:
return fmt.Errorf("unknown api option %q", name)
}
if !inline {
if i+1 >= len(args) || strings.HasPrefix(args[i+1], "-") {
return fmt.Errorf("%s requires a value", name)
}
i++
value = args[i]
}
if strings.TrimSpace(value) == "" {
return fmt.Errorf("%s requires a value", name)
}
if readOnly && name != "--api" {
return fmt.Errorf("api %s does not accept %s", command, name)
}
switch name {
case "--api":
o.API = value
case "--dir":
o.Dir = value
case "--module":
o.Module = value
case "--framework-dir":
o.FrameworkDir = value
case "--framework-version":
o.FrameworkVersion = value
case "--database":
o.Database = value
case "--storage":
o.Storage = value
case "--log-output":
o.LogOutput = value
case "--log-format":
o.LogFormat = value
}
}
if command != "new" && o.API == "" {
dir := o.Dir
if dir == "" {
dir = "."
}
var err error
o.API, err = apigen.ProjectEntry(dir)
if err != nil {
return err
}
}
if command == "format" {
changes, err := apigen.FormatProject(o.API, o.Check)
if err != nil {
return err
}
fmt.Printf("API format: %d files updated\n", len(changes))
return nil
}
if readOnly {
spec, err := apigen.Parse(o.API)
if err != nil {
return err
}
compiled, err := apigen.Compile(spec)
if err != nil {
return err
}
switch command {
case "validate":
fmt.Printf("valid API: %d types, %d routes, %d errors, %d auth schemes\n", len(spec.Types), len(spec.Routes), len(spec.Errors), len(spec.Auth))
case "export":
fmt.Println(string(compiled.Document))
case "inspect":
writer := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(writer, "METHOD\tPATH\tHANDLER\tAUTH\tRESPONSE\tERRORS")
definitions := map[string]apigen.ErrorDefinition{}
for _, e := range spec.Errors {
definitions[e.Name] = e
}
for _, r := range compiled.Spec.Routes {
auth := r.Auth
if auth == "" {
auth = "(undeclared)"
}
var errs []string
for _, name := range r.Errors {
e := definitions[name]
errs = append(errs, fmt.Sprintf("%d:%s", e.Status, e.Code))
}
format := r.ResponseFormat
if format == "" {
format = "default"
}
fmt.Fprintf(writer, "%s\t%s\t%s/%s\t%s\t%s\t%s\n", strings.ToUpper(r.Method), r.Path, r.Group, r.Handler, auth, format, strings.Join(errs, ", "))
}
return writer.Flush()
}
return nil
}
var out apigen.Output
var err error
if command == "new" {
out, err = apigen.New(o)
} else {
if o.Dir == "" {
o.Dir = "."
}
out, err = apigen.Generate(o)
}
printAPIDiagnostics(out.Diagnostics)
if err != nil {
return err
}
if o.DryRun {
for _, change := range out.Changes {
fmt.Printf("%-7s %s\n", change.Action, change.Path)
}
fmt.Printf("Preview: %d changes; %d existing developer-owned files preserved; no files written\n", len(out.Changes), len(out.Preserved))
} else if o.Check {
fmt.Println("API generated files are current; no definite Logic signature errors found (run go test ./... for full verification)")
} else {
fmt.Printf("API project generated: %d generated, %d created, %d preserved, %d removed\n", len(out.Generated), len(out.Created), len(out.Preserved), len(out.Removed))
}
return nil
}
func printAPIDiagnostics(diagnostics []apigen.Diagnostic) {
for _, d := range diagnostics {
location := d.Path
if d.Line > 0 {
location += fmt.Sprintf(":%d", d.Line)
}
fmt.Printf("%s [%s] %s", strings.ToUpper(d.Severity), d.Code, location)
if d.Operation != "" {
fmt.Printf(" (%s)", d.Operation)
}
fmt.Printf(": %s\n", d.Message)
if d.Before != "" {
fmt.Printf(" before: %s\n", d.Before)
}
if d.After != "" {
fmt.Printf(" after: %s\n", d.After)
}
for _, detail := range d.Details {
fmt.Printf(" - %s\n", detail)
}
}
}

149
cmd/go-omni/api_test.go Normal file
View File

@@ -0,0 +1,149 @@
package main
import (
"encoding/json"
"io"
"os"
"path/filepath"
"strings"
"testing"
)
func TestAPICommands(t *testing.T) {
root, err := filepath.Abs("../..")
if err != nil {
t.Fatal(err)
}
project := filepath.Join(t.TempDir(), "demo")
if err = runAPI([]string{"new", project, "--module=example.com/demo", "--framework-dir", root}); err != nil {
t.Fatal(err)
}
api := filepath.Join(project, "api/service.api")
if err = runAPI([]string{"validate", "-api", api}); err != nil {
t.Fatal(err)
}
if err = runAPI([]string{"go", "-api", api, "-dir", project, "--check"}); err != nil {
t.Fatal(err)
}
if err = runNew([]string{"--force", project}); err == nil {
t.Fatal("CRUD starter overwrote API project")
}
target := filepath.Join(t.TempDir(), "other")
if err = runNew([]string{"--api", api, "--module", "example.com/other", "--framework-dir", root, target}); err != nil {
t.Fatal(err)
}
if _, err = os.Stat(filepath.Join(target, "internal/logic/greet/greet.go")); err != nil {
t.Fatal(err)
}
for _, args := range [][]string{{"validate"}, {"generate", "--api"}, {"new", "--api=x", "somewhere"}, {"generate", "--dir="}, {"generate", "--force"}, {"validate", "--api", api, "--dir", project}, {"validate", "--check"}} {
if err = runAPI(args); err == nil {
t.Fatalf("invalid args accepted: %v", args)
}
}
if err = runNew([]string{"--api", api, "--force", target}); err == nil || !strings.Contains(err.Error(), "do not accept --force") {
t.Fatalf("incompatible profile: %v", err)
}
}
func TestAPIDeveloperCommands(t *testing.T) {
root, err := filepath.Abs("../..")
if err != nil {
t.Fatal(err)
}
dir := filepath.Join(t.TempDir(), "project")
if err = runAPI([]string{"new", dir, "--framework-dir", root, "--module", "example.com/commands"}); err != nil {
t.Fatal(err)
}
// Generation can resolve the entry from an explicit target directory.
if err = runAPI([]string{"generate", "--dir", dir, "--dry-run"}); err != nil {
t.Fatal(err)
}
wd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err = os.Chdir(dir); err != nil {
t.Fatal(err)
}
defer os.Chdir(wd)
for _, args := range [][]string{{"validate"}, {"inspect"}, {"generate", "--check"}} {
if err = runAPI(args); err != nil {
t.Fatalf("%v: %v", args, err)
}
}
doc := captureAPIOutput(t, []string{"export"})
var parsed map[string]any
if err = json.Unmarshal([]byte(doc), &parsed); err != nil {
t.Fatalf("export must be pure JSON: %v", err)
}
if parsed["openapi"] != "3.0.3" {
t.Fatal("wrong export")
}
for _, args := range [][]string{{"generate", "--dry-run", "--check"}, {"new", "--dry-run", "other"}, {"inspect", "--dir", dir}, {"export", "--log-output", "file"}} {
if err = runAPI(args); err == nil {
t.Fatalf("accepted invalid flags: %v", args)
}
}
}
func captureAPIOutput(t *testing.T, args []string) string {
t.Helper()
data, err := captureAPIResult(t, args)
if err != nil {
t.Fatal(err)
}
return data
}
func captureAPIResult(t *testing.T, args []string) (string, error) {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
previous := os.Stdout
os.Stdout = w
defer func() { os.Stdout = previous; r.Close(); w.Close() }()
result := make(chan []byte, 1)
go func() { data, _ := io.ReadAll(r); result <- data }()
callErr := runAPI(args)
w.Close()
os.Stdout = previous
data := <-result
return string(data), callErr
}
func TestAPIChangeDiagnosticsVisibleOnFailure(t *testing.T) {
root, err := filepath.Abs("../..")
if err != nil {
t.Fatal(err)
}
dir := filepath.Join(t.TempDir(), "app")
captureAPIOutput(t, []string{"new", dir, "--module", "example.com/diagnostics", "--framework-dir", root})
api := filepath.Join(dir, "api/service.api")
data, err := os.ReadFile(api)
if err != nil {
t.Fatal(err)
}
if err = os.WriteFile(api, []byte(strings.ReplaceAll(string(data), "GreetReq", "HelloReq")), 0644); err != nil {
t.Fatal(err)
}
for _, flag := range []string{"--dry-run", "--check", ""} {
args := []string{"generate", "--dir", dir}
if flag != "" {
args = append(args, flag)
}
text, err := captureAPIResult(t, args)
if (flag == "--check") != (err != nil) {
t.Fatalf("%s: %v", flag, err)
}
for _, want := range []string{"REVIEW [contractChanged]", "ERROR [logicSignature]", "internal/logic/greet/greet.go:", "types.HelloReq", "types.GreetReq"} {
if !strings.Contains(text, want) {
t.Fatalf("%s hides %q:\n%s", flag, want, text)
}
}
}
text, err := captureAPIResult(t, []string{"generate", "--dir", dir, "--check"})
if err == nil || !strings.Contains(text, "ERROR [logicSignature]") || strings.Contains(text, "files are current") {
t.Fatalf("check claimed success: %v\n%s", err, text)
}
}

View File

@@ -0,0 +1,361 @@
package apigen
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func frameworkRoot(t *testing.T) string {
t.Helper()
p, err := filepath.Abs("../../..")
if err != nil {
t.Fatal(err)
}
return p
}
func writeFile(t *testing.T, name, body string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(name), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(name, []byte(body), 0644); err != nil {
t.Fatal(err)
}
}
func readFile(t *testing.T, name string) string {
t.Helper()
b, err := os.ReadFile(name)
if err != nil {
t.Fatal(err)
}
return string(b)
}
func parseText(t *testing.T, source string) (*Spec, error) {
t.Helper()
file := filepath.Join(t.TempDir(), "service.api")
writeFile(t, file, source)
return Parse(file)
}
func TestCompileExample(t *testing.T) {
s, err := Parse(filepath.Join(frameworkRoot(t), "examples/contracts/user.api"))
if err != nil {
t.Fatal(err)
}
c, err := Compile(s)
if err != nil {
t.Fatal(err)
}
if len(c.Groups) != 2 || len(s.Routes) != 3 || len(s.Sources) != 2 {
t.Fatalf("wrong spec: %+v", s)
}
var doc map[string]any
if err = json.Unmarshal(c.Document, &doc); err != nil {
t.Fatal(err)
}
route := doc["paths"].(map[string]any)["/api/v1/users/{userId}"].(map[string]any)["put"].(map[string]any)
if len(route["parameters"].([]any)) != 3 {
t.Fatal("missing mixed parameters")
}
schema := route["requestBody"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any)
if schema["additionalProperties"] != false || len(schema["required"].([]any)) != 2 {
t.Fatalf("wrong body schema: %+v", schema)
}
if strings.Contains(string(c.Document), `"user_id"`) {
t.Fatal("snake case wire name")
}
}
func TestInvalidDefinitions(t *testing.T) {
cases := []string{
`syntax="v2"`,
`@server ( jwt: Auth ) service user { @handler go get /go }`,
"type Request { Name string `json:\"name\" binding:\"typo\"` }\nservice user { @handler run\n post /go (Request) }",
"type A { Other *A `json:\"other,optional\"` }\nservice user { @handler run\n post /go (A) }",
"type A { Other Missing `json:\"other\"` }\nservice user { @handler run\n post /go (A) }",
"type A { UserID int `json:\"user_id\"` }\nservice user { @handler run\n post /go (A) }",
"service user { @handler go\n get /go\n @handler other\n get /go\n}",
"service user { @handler go\n get /:id\n}",
"service user { @handler service\n get /go\n}",
"type A { ID int `path:\"id\"` }\nservice user { @handler go\n get /go (A)\n}",
"type A { Name string `json:\"name\"` }\nservice user { @handler go\n get /go (A)\n}",
"service user { @handler go\n @status 201\n post /go\n}",
"type A { Name string `header:\"Name\"` }\nservice user { @handler go\n get /go returns (A)\n}",
"@server (group: ../../bad)\nservice user { @handler go\n get /go\n}",
"type A { Name string `json:\"name\"`\n Name int `json:\"other\"` }\nservice user { @handler go\n post /go (A)\n}",
"service user { @handler go\n get /go\n @handler Go\n get /other\n}",
"type A { Value []byte `json:\"value\"` }\nservice user { @handler go\n post /go (A)\n}",
}
for i, source := range cases {
s, err := parseText(t, source)
if err == nil {
_, err = Compile(s)
}
if err == nil {
t.Fatalf("case %d accepted: %s", i, source)
}
}
}
func TestImports(t *testing.T) {
root := t.TempDir()
entry := filepath.Join(root, "api", "entry.api")
writeFile(t, entry, "import \"types.api\"\nservice user { @handler ping\n get /ping\n}")
writeFile(t, filepath.Join(root, "api/types.api"), `import "entry.api"`)
if _, err := Parse(entry); err == nil || !strings.Contains(err.Error(), "circular") {
t.Fatalf("cycle: %v", err)
}
writeFile(t, entry, "import \"../outside.api\"\nservice user { @handler ping\n get /ping\n}")
writeFile(t, filepath.Join(root, "outside.api"), `type X { Value string `+"`json:\"value\"`"+` }`)
if _, err := Parse(entry); err == nil {
t.Fatal("escaped import root")
}
writeFile(t, entry, "import (\n \"shared.api\"\n \"./shared.api\"\n)\nservice user { @handler ping\n get /ping\n}")
writeFile(t, filepath.Join(root, "api/shared.api"), `type X { Value string `+"`json:\"value\"`"+` }`)
s, err := Parse(entry)
if err != nil || len(s.Types) != 1 {
t.Fatalf("duplicate import: %+v %v", s, err)
}
}
func TestSharedPrefixConflict(t *testing.T) {
source := "type A { ID int `path:\"id\"` }\ntype B { Name string `path:\"name\"` }\nservice user {\n @handler first\n get /users/:id (A)\n @handler second\n get /users/:name/items (B)\n}"
s, err := parseText(t, source)
if err != nil {
t.Fatal(err)
}
if _, err = Compile(s); err == nil {
t.Fatal("shared prefix conflict was accepted")
}
}
func FuzzParser(f *testing.F) {
for _, s := range []string{starterAPI, declaredAPI, "error Broken", "auth Auth (type: bearer)", "syntax = \"v1\"", "type (", "@server (prefix: /api)"} {
f.Add(s)
}
f.Fuzz(func(t *testing.T, source string) {
if len(source) > 16384 {
t.Skip()
}
tokens, err := lex("fuzz.api", source)
if err != nil {
return
}
spec := &Spec{Title: "Fuzz", Version: "1"}
p := parser{tokens: tokens, spec: spec}
if p.parse() == nil {
_, _ = Compile(spec)
}
})
}
func TestRegenerationAndConflicts(t *testing.T) {
dir := filepath.Join(t.TempDir(), "app")
if _, err := New(Options{Dir: dir, Module: "example.com/test", FrameworkDir: frameworkRoot(t)}); err != nil {
t.Fatal(err)
}
o := Options{Dir: dir, API: filepath.Join(dir, "api/service.api")}
if _, err := Generate(o); err != nil {
t.Fatal(err)
}
manifestBefore := readFile(t, filepath.Join(dir, manifestPath))
typesBefore := readFile(t, filepath.Join(dir, "internal/types/typesGen.go"))
o.Check = true
if _, err := Generate(o); err != nil {
t.Fatal(err)
}
servicePath := filepath.Join(dir, "internal/logic/greet/greet.go")
service := readFile(t, servicePath) + "\n// user edit must survive\n"
writeFile(t, servicePath, service)
writeFile(t, filepath.Join(dir, "internal/repository/greet/repository.go"), "package greet\n")
for _, file := range []string{"etc/config.yaml", "internal/svc/serviceContext.go", "internal/repository/greet/repository.go"} {
body := readFile(t, filepath.Join(dir, file))
if strings.HasSuffix(file, ".go") {
body += "\n// handwritten extension\n"
} else {
body += "\n# handwritten extension\n"
}
writeFile(t, filepath.Join(dir, file), body)
}
api := readFile(t, o.API)
api += "\n@server (group: extra)\nservice greet-api {\n @handler ping\n get /ping\n}\n"
writeFile(t, o.API, api)
if _, err := Generate(o); err == nil {
t.Fatal("check accepted stale output")
}
if readFile(t, filepath.Join(dir, manifestPath)) != manifestBefore {
t.Fatal("--check wrote manifest")
}
o.Check = false
if _, err := Generate(o); err != nil {
t.Fatal(err)
}
if readFile(t, servicePath) != service {
t.Fatal("business code overwritten")
}
// Retiring a group removes only owned handlers, keeping its business files.
writeFile(t, o.API, strings.Split(api, "\n@server (group: extra)")[0])
if _, err := Generate(o); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, "internal/handler/extra/handlersGen.go")); !os.IsNotExist(err) {
t.Fatal("stale handler remains")
}
if _, err := os.Stat(filepath.Join(dir, "internal/logic/extra/ping.go")); err != nil {
t.Fatal("business stub removed")
}
writeFile(t, filepath.Join(dir, "internal/types/typesGen.go"), typesBefore+"\n// edited generated file\n")
before := snapshot(t, dir)
if _, err := Generate(o); err == nil || !strings.Contains(err.Error(), "was edited") {
t.Fatalf("did not protect edited output: %v", err)
}
after := snapshot(t, dir)
if !sameFiles(before, after) {
t.Fatal("failed regeneration changed files")
}
}
func TestOutputBoundaries(t *testing.T) {
for _, kind := range []string{"symlink", "unowned", "lock", "caseCollision"} {
t.Run(kind, func(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "app")
api := filepath.Join(root, "input/service.api")
writeFile(t, api, starterAPI)
switch kind {
case "symlink":
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatal(err)
}
outside := filepath.Join(root, "outside")
os.MkdirAll(outside, 0755)
if err := os.Symlink(outside, filepath.Join(dir, "internal")); err != nil {
t.Fatal(err)
}
case "unowned":
writeFile(t, filepath.Join(dir, "internal/types/typesGen.go"), "package types // mine\n")
case "lock":
writeFile(t, filepath.Join(dir, ".goOmni/api.lock"), "someone else")
case "caseCollision":
writeFile(t, api, starterAPI+"\n@server (group: gReet)\nservice greet-api {\n @handler extra\n get /extra\n}\n")
}
before := snapshot(t, root)
if _, err := Generate(Options{Dir: dir, API: api, Module: "example.com/test", FrameworkDir: frameworkRoot(t)}); err == nil {
t.Fatal("unsafe output accepted")
}
if !sameFiles(before, snapshot(t, root)) {
t.Fatal("conflict changed files")
}
})
}
}
func snapshot(t *testing.T, root string) map[string]string {
t.Helper()
out := map[string]string{}
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.Type().IsRegular() {
data, e := os.ReadFile(path)
if e != nil {
return e
}
rel, _ := filepath.Rel(root, path)
out[rel] = string(data)
}
return nil
})
if err != nil {
t.Fatal(err)
}
return out
}
func sameFiles(a, b map[string]string) bool {
if len(a) != len(b) {
return false
}
for k, v := range a {
if b[k] != v {
return false
}
}
return true
}
func TestGeneratedProjectsBuildAndRun(t *testing.T) {
if testing.Short() {
t.Skip("consumer module integration")
}
root := frameworkRoot(t)
for _, starter := range []bool{true, false} {
name := "contract"
if starter {
name = "starter"
}
t.Run(name, func(t *testing.T) {
dir := filepath.Join(t.TempDir(), "project")
o := Options{Dir: dir, Module: "example.com/consumer", FrameworkDir: root, LogOutput: "file", LogFormat: "text"}
var err error
if starter {
_, err = New(o)
} else {
writeFile(t, filepath.Join(dir, generatorConfigFile), `{"middleware":"handlers"}`)
o.API = filepath.Join(root, "examples/contracts/user.api")
_, err = Generate(o)
}
if err != nil {
t.Fatal(err)
}
source := consumerContractTest
if starter {
source = consumerStarterTest
} else {
writeFile(t, filepath.Join(dir, "internal/logic/user/updateUser.go"), `package user
import("context";"example.com/consumer/internal/types")
func(s *Logic) UpdateUser(ctx context.Context,req *types.UpdateUserReq)(*types.UserResp,error){return &types.UserResp{UserID:req.UserID,Name:req.Name,Active:req.Active},nil}
`)
}
writeFile(t, filepath.Join(dir, "contract_test.go"), source)
o.API = filepath.Join(dir, "api/service.api")
if !starter {
o.API = filepath.Join(dir, "api/user.api")
}
if _, err = Generate(o); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "go", "test", "-mod=mod", "-race", "-count=1", "./...")
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off", "GOFLAGS=")
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("consumer: %v\n%s", err, output)
}
t.Log(string(output))
})
}
}
const consumerStarterTest = `package main_test
import("context";"io";"net/http/httptest";"strings";"testing";omni "git.yuntuo.hk/k2safe/go-omni";"git.yuntuo.hk/k2safe/go-omni/logx";"example.com/consumer/internal/handler";"example.com/consumer/internal/svc";"example.com/consumer/internal/config")
func TestGreeting(t *testing.T){cfg,err:=config.Load("etc/config.yaml");if err!=nil{t.Fatal(err)};cfg.Server.Log.Writer=io.Discard;cfg.Server.Log.File="";cfg.Server.Log.Rotation=logx.RotationConfig{};logger,err:=logx.New(cfg.Server.Log);if err!=nil{t.Fatal(err)};defer logger.Close();deps,err:=svc.New(context.Background(),logger);if err!=nil{t.Fatal(err)};defer deps.Close();app:=omni.New();defer app.Close();if err=handler.RegisterRoutes(app,deps);err!=nil{t.Fatal(err)};w:=httptest.NewRecorder();app.Engine().ServeHTTP(w,httptest.NewRequest("GET","/api/greet/Alice",nil));if w.Code!=200||!strings.Contains(w.Body.String(),"Hello Alice")||w.Header().Get("X-Request-ID")==""{t.Fatalf("%d %s",w.Code,w.Body.String())}}
`
const consumerContractTest = `package main_test
import("context";"encoding/json";"io";"net/http/httptest";"strings";"testing";omni "git.yuntuo.hk/k2safe/go-omni";"git.yuntuo.hk/k2safe/go-omni/logx";"example.com/consumer/internal/handler";"example.com/consumer/internal/svc")
func TestContract(t *testing.T){cfg:=logx.Default();cfg.Writer=io.Discard;logger,err:=logx.New(cfg);if err!=nil{t.Fatal(err)};defer logger.Close();deps,err:=svc.New(context.Background(),logger);if err!=nil{t.Fatal(err)};defer deps.Close();app:=omni.New();defer app.Close();if err=handler.RegisterRoutes(app,deps);err!=nil{t.Fatal(err)}
for _,tc:=range []struct{method,path,body string;status int}{
{"PUT","/api/v1/users/7?notify=true",` + "`" + `{"name":"Alice","active":false,"age":0}` + "`" + `,200},
{"PUT","/api/v1/users/7",` + "`" + `{"name":"Alice"}` + "`" + `,400},
{"PUT","/api/v1/users/7?notify=true&notify=false",` + "`" + `{"name":"Alice","active":false}` + "`" + `,400},
{"PUT","/api/v1/users/7",` + "`" + `{"name":"Alice","active":false,"age":151}` + "`" + `,400},
{"GET","/api/v1/users?page=0","",400},
{"GET","/api/v1/users?page=1","",501},
{"DELETE","/admin/v1/users/7","",501},
}{req:=httptest.NewRequest(tc.method,tc.path,strings.NewReader(tc.body));req.Header.Set("Content-Type","application/json");w:=httptest.NewRecorder();app.Engine().ServeHTTP(w,req);if w.Code!=tc.status{t.Fatalf("%s: %d %s",tc.path,w.Code,w.Body.String())};if tc.status==200{var v struct{Data struct{UserID int64 ` + "`json:\"userId\"`" + `;Name string ` + "`json:\"name\"`" + `}};if err:=json.Unmarshal(w.Body.Bytes(),&v);err!=nil||v.Data.UserID!=7||v.Data.Name!="Alice"{t.Fatal(w.Body.String())}}}
delete(deps.Middlewares,"Auth");if err:=handler.RegisterRoutes(omni.New(),deps);err==nil{t.Fatal("missing auth hook accepted")}
}
`

View File

@@ -0,0 +1,104 @@
package apigen
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func TestPrimitiveArrayContracts(t *testing.T) {
for _, name := range []string{"string", "bool", "int", "int8", "int16", "int32", "int64", "uint", "uint16", "uint32", "uint64", "float32", "float64", "any"} {
t.Run(name, func(t *testing.T) {
spec, err := parseText(t, fmt.Sprintf("service arrays{\n@handler list\nget /list returns ([]%s)\n}", name))
if err != nil {
t.Fatal(err)
}
c, err := Compile(spec)
if err != nil {
t.Fatal(err)
}
schema := operation(t, document(t, c), "/list", "get")["responses"].(map[string]any)["200"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any)
data := schema["properties"].(map[string]any)["data"].(map[string]any)
if data["type"] != "array" || data["items"] == nil {
t.Fatal(data)
}
if name == "any" && len(data["items"].(map[string]any)) != 0 {
t.Fatal("dynamic JSON was constrained")
}
if responseGoType("[]"+name) != "[]"+name || responseNeedsTypes("[]"+name) {
t.Fatal("wrong Go signature/import")
}
})
}
for _, name := range []string{"uint8", "File", "Unknown", "[]string"} {
spec, err := parseText(t, fmt.Sprintf("service arrays{\n@handler list\nget /list returns ([]%s)\n}", name))
if err == nil {
_, err = Compile(spec)
}
if err == nil {
t.Fatal("unsupported root accepted", name)
}
}
}
func TestPrimitiveArrayConsumer(t *testing.T) {
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") != "1" {
t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1")
}
dir := filepath.Join(t.TempDir(), "arrays")
o := Options{Dir: dir, API: filepath.Join(frameworkRoot(t), "examples/contracts/arrays.api"), Module: "example.com/arrays", FrameworkDir: frameworkRoot(t)}
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
for _, name := range []string{"strings", "integers", "dynamic", "empty", "raw"} {
writeFile(t, filepath.Join(dir, "internal/logic/arrays/"+name+".go"), "package arrays\n")
}
writeFile(t, filepath.Join(dir, "internal/logic/arrays/values.go"), readFile(t, "testdata/arrays/values.go"))
writeFile(t, filepath.Join(dir, "consumer_test.go"), readFile(t, "testdata/arrays/consumer_test.go"))
o.API = filepath.Join(dir, "api/arrays.api")
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
if !strings.Contains(readFile(t, filepath.Join(dir, "internal/logic/arrays/values.go")), "企业") {
t.Fatal("logic overwritten")
}
o.Check = true
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "go", "test", "-race", "-mod=mod", "-count=1", "./...")
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("%v\n%s", err, out)
}
if os.Getenv("GO_OMNI_RELEASE_CLI") != "" {
cmd = exec.CommandContext(ctx, "go", "list", "-m", "-json", frameworkModule)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off")
raw, err := cmd.CombinedOutput()
if err != nil {
t.Fatal(err, string(raw))
}
var module struct {
Version, Sum string
Replace any
}
if err = json.Unmarshal(raw, &module); err != nil {
t.Fatal(err)
}
if module.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || module.Replace != nil || module.Sum == "" {
t.Fatal("not a fixed remote consumer")
}
t.Logf("remote framework %s checksum %s; no local replace", module.Version, module.Sum)
}
}

View File

@@ -0,0 +1,92 @@
package apigen
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func TestBusinessContract(t *testing.T) {
spec, err := Parse(filepath.Join(frameworkRoot(t), "examples/contracts/business.api"))
if err != nil {
t.Fatal(err)
}
c, err := Compile(spec)
if err != nil {
t.Fatal(err)
}
var doc map[string]any
if err = json.Unmarshal(c.Document, &doc); err != nil {
t.Fatal(err)
}
paths := doc["paths"].(map[string]any)
body := paths["/optional"].(map[string]any)["post"].(map[string]any)["requestBody"].(map[string]any)
if body["required"] != false {
t.Fatal("optional body documented as required")
}
for _, path := range []string{"/empty", "/absent"} {
if paths[path].(map[string]any)["post"].(map[string]any)["x-omni-request-policy"] == nil {
t.Fatal("missing policy metadata")
}
}
}
func TestBusinessGeneratedProject(t *testing.T) {
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") != "1" {
t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1")
}
dir := filepath.Join(t.TempDir(), "business")
writeFile(t, filepath.Join(dir, generatorConfigFile), `{"middleware":"handlers"}`)
o := Options{Dir: dir, API: filepath.Join(frameworkRoot(t), "examples/contracts/business.api"), Module: "example.com/business", FrameworkDir: frameworkRoot(t)}
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
for _, name := range []string{"upload", "nullable", "nothing", "rawNull", "rawArray", "empty", "absent", "optional", "echo", "tolerant", "noContent"} {
writeFile(t, filepath.Join(dir, "internal/logic/demo/"+name+".go"), "package demo\n")
}
writeFile(t, filepath.Join(dir, "internal/logic/demo/list.go"), readFile(t, "testdata/business/operations.go"))
writeFile(t, filepath.Join(dir, "consumer_test.go"), readFile(t, "testdata/business/consumer_test.go"))
writeFile(t, filepath.Join(dir, "internal/middleware/accessFactory.go"), readFile(t, "testdata/business/accessFactory.go"))
writeFile(t, filepath.Join(dir, "internal/svc/client.go"), readFile(t, "testdata/business/client.go"))
svcFile := filepath.Join(dir, "internal/svc/serviceContext.go")
source := strings.Replace(readFile(t, svcFile), "type ServiceContext struct {", "type ServiceContext struct {\n Client *accessClient", 1)
source = strings.Replace(source, "return nil", "return c.initAccess(ctx)", 1)
writeFile(t, svcFile, source)
o.API = filepath.Join(dir, "api/business.api")
o.Check = true
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "go", "test", "-race", "-mod=mod", "-count=1", "./...")
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("%v\n%s", err, out)
}
if os.Getenv("GO_OMNI_RELEASE_CLI") != "" {
cmd = exec.CommandContext(ctx, "go", "list", "-m", "-json", frameworkModule)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off")
raw, err := cmd.CombinedOutput()
if err != nil {
t.Fatal(err, string(raw))
}
var module struct {
Version, Sum string
Replace any
}
if err = json.Unmarshal(raw, &module); err != nil {
t.Fatal(err)
}
if module.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || module.Replace != nil || module.Sum == "" {
t.Fatal("not a fixed remote consumer")
}
t.Logf("remote framework %s checksum %s; no local replace", module.Version, module.Sum)
}
}

View File

@@ -0,0 +1,161 @@
package apigen
import (
"errors"
"fmt"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/response"
"io"
"os"
"path/filepath"
"strings"
)
const catalogDocumentFile = "omni.errors.json"
// DocumentErrors selects documentation only. Runtime App inheritance and
// route-local @errors declarations are never changed by this metadata.
// A nil Include lists all errors; [] lists none. Routes override Include.
type DocumentErrors struct {
Source string `json:"source"`
Include []string `json:"include"`
Routes map[string][]string `json:"routes,omitempty"`
}
func readDocumentCatalog(root string, spec *Spec, overrides map[string][]byte) error {
name := catalogDocumentFile
path := filepath.Join(root, name)
// Shared metadata uses the same explicitly authorized roots as .api imports.
if selection := spec.DocumentErrors; selection != nil {
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
return errors.New("api: use either local omni.errors.json or shared errors.source, not both")
}
name = selection.Source
alias, sub, ok := strings.Cut(strings.TrimPrefix(name, "@"), "/")
declared, found := spec.SharedRoots[alias]
if !strings.HasPrefix(name, "@") || !ok || !found || sub == "" || filepath.Ext(sub) != ".json" {
return errors.New("api: errors.source must name a JSON file in a declared @root")
}
if err := safePath(declared.Path, sub); err != nil {
return err
}
path = filepath.Join(declared.Path, filepath.FromSlash(sub))
}
raw, ok := overrides[path]
if !ok {
stat, err := os.Lstat(path)
if errors.Is(err, os.ErrNotExist) && spec.DocumentErrors == nil {
return nil
}
if err != nil {
return err
}
if !stat.Mode().IsRegular() {
return errors.New("api: error metadata must be a regular file")
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
raw, err = io.ReadAll(io.LimitReader(f, (1<<20)+1))
if err != nil {
return err
}
}
if len(raw) > 1<<20 {
return errors.New("api: error metadata exceeds 1 MiB")
}
var catalog apierrors.Catalog
if err := apierrors.DecodeCatalogJSON(raw, &catalog); err != nil {
return err
}
registry, err := apierrors.NewRegistry(catalog)
if err != nil {
return err
}
normalized := registry.Catalog()
spec.DocumentCatalog = &normalized
spec.Sources[name] = raw
spec.SourcePaths[name] = path
return validateDocumentSelection(spec)
}
func documentRouteCatalog(spec *Spec, route Route) apierrors.Catalog {
catalog := spec.DocumentCatalog.Clone()
if spec.DocumentErrors == nil {
return catalog
}
include := spec.DocumentErrors.Include
if selected, ok := spec.DocumentErrors.Routes[strings.ToUpper(route.Method)+" "+route.Path]; ok {
include = selected
}
if include != nil {
selected := map[string]apierrors.Definition{}
for _, key := range include {
selected[key] = catalog.Definitions[key]
}
catalog.Definitions = selected
}
return catalog
}
func validateDocumentSelection(spec *Spec) error {
cfg := spec.DocumentErrors
if cfg == nil {
return nil
}
validate := func(keys []string) error {
seen := map[string]bool{}
for _, key := range keys {
if _, ok := spec.DocumentCatalog.Definitions[key]; !ok {
return fmt.Errorf("api: unknown documentation error %q", key)
}
if seen[key] {
return fmt.Errorf("api: duplicate documentation error %q", key)
}
seen[key] = true
}
return nil
}
if err := validate(cfg.Include); err != nil {
return err
}
routes := map[string]Route{}
for _, r := range spec.Routes {
routes[strings.ToUpper(r.Method)+" "+r.Path] = r
}
for key, keys := range cfg.Routes {
r, ok := routes[key]
if !ok {
return fmt.Errorf("api: unknown documentation route %q", key)
}
if len(r.Errors) > 0 {
return fmt.Errorf("api: %s already declares route-local errors", key)
}
if err := validate(keys); err != nil {
return err
}
}
return nil
}
func validateDocumentCatalog(spec *Spec) error {
if spec.DocumentCatalog == nil {
return nil
}
for _, r := range spec.Routes {
if len(r.Errors) > 0 {
continue
}
policy, err := response.New(responseConfig(spec, r))
if err != nil {
return err
}
if err = policy.ValidateErrors(documentRouteCatalog(spec, r)); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,161 @@
package apigen
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func TestCompatibilityContract(t *testing.T) {
s, err := Parse(filepath.Join(frameworkRoot(t), "examples/contracts/compatibility.api"))
if err != nil {
t.Fatal(err)
}
c, err := Compile(s)
if err != nil {
t.Fatal(err)
}
if len(s.Scalars) != 1 || len(s.Routes) != 6 {
t.Fatal("incomplete contract")
}
if !strings.Contains(string(c.Document), `"anyOf"`) || !strings.Contains(string(c.Document), `"multipart/form-data"`) {
t.Fatal("missing schemas")
}
for _, change := range [][2]string{{"jsonInput: string | int64", "jsonInput: mystery"}, {"text: true", "text: false"}, {"mode: multipart", "mode: invalid"}, {"maxBytes: 128", "maxBytes: 0"}, {"goType: ID", "goType: id"}, {"Data any", "Data interface{ Method() }"}} {
source := readFile(t, filepath.Join(frameworkRoot(t), "examples/contracts/compatibility.api"))
spec, err := parseText(t, strings.Replace(source, change[0], change[1], 1))
if err == nil {
_, err = Compile(spec)
}
if err == nil {
t.Fatalf("accepted %s", change[1])
}
}
}
func TestCompatibilityConsumer(t *testing.T) {
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") == "" {
t.Skip("consumer integration")
}
root := frameworkRoot(t)
dir := filepath.Join(t.TempDir(), "consumer")
o := Options{API: filepath.Join(root, "examples/contracts/compatibility.api"), Dir: dir, Module: "example.com/compatibility", FrameworkDir: root}
releaseCLI := os.Getenv("GO_OMNI_RELEASE_CLI")
generate := func(check bool) {
t.Helper()
if releaseCLI == "" {
options := o
options.Check = check
if _, err := Generate(options); err != nil {
t.Fatal(err)
}
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
args := []string{"api", "generate", "--api", o.API, "--dir", dir, "--module", o.Module}
if check {
args = append(args, "--check")
}
cmd := exec.CommandContext(ctx, releaseCLI, args...)
cmd.Dir = filepath.Dir(dir)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("released CLI: %v\n%s", err, output)
}
}
generate(false)
files := map[string]string{
"internal/logic/demo/echo.go": "echo.go",
"internal/handler/demo/customBind.go": "hooks.go",
"internal/handler/files/downloadManual.go": "download.go",
"consumer_test.go": "consumer_test.go",
}
// The fixture implements related methods together; remove only its generated
// one-time stubs, then fill every stub so regeneration can preserve all files.
for _, name := range []string{"internal/logic/demo/item.go", "internal/logic/demo/custom.go"} {
writeFile(t, filepath.Join(dir, name), "package demo\n")
}
for _, name := range []string{"internal/handler/demo/uploadManual.go", "internal/handler/demo/callbackManual.go"} {
writeFile(t, filepath.Join(dir, name), "package demo\n")
}
for target, source := range files {
writeFile(t, filepath.Join(dir, target), readFile(t, filepath.Join("testdata/compatibility", source)))
}
before := map[string]string{}
for target := range files {
before[target] = readFile(t, filepath.Join(dir, target))
}
generate(false)
for target, content := range before {
if readFile(t, filepath.Join(dir, target)) != content {
t.Fatalf("overwrote %s", target)
}
}
generate(true)
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "go", "test", "-mod=mod", "-race", "-count=1", "./...")
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off")
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("consumer: %v\n%s", err, output)
}
if releaseCLI != "" {
cmd = exec.CommandContext(ctx, "go", "list", "-m", "-json", frameworkModule)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off")
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("module: %v\n%s", err, output)
}
var module struct {
Version, Sum string
Replace any
}
if err = json.Unmarshal(output, &module); err != nil {
t.Fatal(err)
}
if module.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || module.Replace != nil || module.Sum == "" {
t.Fatalf("not a fixed remote dependency: %+v", module)
}
t.Logf("remote framework %s checksum %s; no local replace", module.Version, module.Sum)
}
}
func TestNestedDynamicAliases(t *testing.T) {
source := readFile(t, filepath.Join(frameworkRoot(t), "examples/contracts/compatibility.api"))
for _, typ := range []string{"[]interface{}", "map[string]interface{}", "map[string][]interface{}"} {
s, err := parseText(t, strings.Replace(source, "Data any", "Data "+typ, 1))
if err == nil {
_, err = Compile(s)
}
if err != nil {
t.Fatalf("%s: %v", typ, err)
}
}
}
func TestSwitchFromManualRequiresExplicitMigration(t *testing.T) {
root := frameworkRoot(t)
o := Options{API: filepath.Join(root, "examples/contracts/compatibility.api"), Dir: filepath.Join(t.TempDir(), "consumer"), Module: "example.com/compatibility", FrameworkDir: root}
if _, err := Generate(o); err != nil {
t.Fatal(err)
}
// The hand-written source may contain business code. Never delete it to make
// the newly automatic handler compile; fail before touching generated files.
o.API = filepath.Join(o.Dir, "api/compatibility.api")
source := readFile(t, o.API)
source = strings.Replace(source, "@handlerMode manual\n @produces \"application/octet-stream\"", "", 1)
writeFile(t, o.API, source)
before := snapshot(t, o.Dir)
if _, err := Generate(o); err == nil || !strings.Contains(err.Error(), "migrate and remove") {
t.Fatalf("unsafe mode switch: %v", err)
}
if !sameFiles(before, snapshot(t, o.Dir)) {
t.Fatal("failed mode switch mutated project")
}
}

View File

@@ -0,0 +1,370 @@
package apigen
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"go/ast"
goparser "go/parser"
gotoken "go/token"
"reflect"
"regexp"
"sort"
"strings"
"git.yuntuo.hk/k2safe/go-omni/contract"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/openapi"
)
type Compiled struct {
Spec *Spec
Bindings map[string]*contract.Binder
Document []byte
Groups []string
}
var exportedName = regexp.MustCompile(`^[A-Z][A-Za-z0-9]*$`)
var goName = regexp.MustCompile(`^[a-zA-Z][A-Za-z0-9]*$`)
var lowerName = regexp.MustCompile(`^[a-z][A-Za-z0-9]*$`)
var serviceName = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9-]*$`)
var staticSegment = regexp.MustCompile(`^[A-Za-z0-9._~-]+$`)
func Compile(spec *Spec) (*Compiled, error) {
if spec == nil || len(spec.Routes) == 0 || !serviceName.MatchString(spec.Service) {
return nil, errors.New("api: valid service and routes required")
}
var prepareErr error
spec, prepareErr = prepareMetadata(spec)
if prepareErr != nil {
return nil, prepareErr
}
if err := expandFields(spec); err != nil {
return nil, err
}
if err := validateDocumentCatalog(spec); err != nil {
return nil, err
}
if err := spec.RequestPolicy.Validate(); err != nil {
return nil, err
}
definitions := map[string]Type{}
for _, t := range spec.Types {
if t.Name == "File" {
return nil, errors.New("File is reserved for multipart uploads")
}
if t.Name == "ContractScalars" && len(spec.Scalars) > 0 {
return nil, errors.New("ContractScalars is reserved when declaring scalars")
}
if !exportedName.MatchString(t.Name) {
return nil, fmt.Errorf("%s: type names must be UpperCamelCase", t.Position)
}
if _, ok := definitions[t.Name]; ok {
return nil, fmt.Errorf("%s: duplicate type %s", t.Position, t.Name)
}
definitions[t.Name] = t
}
scalarTypes, scalarDefs, err := compileScalars(spec, definitions)
if err != nil {
return nil, err
}
resolved := map[string]reflect.Type{}
resolving := map[string]bool{}
var resolve func(string, int) (reflect.Type, error)
var expression func(ast.Expr, int) (reflect.Type, error)
expression = func(e ast.Expr, depth int) (reflect.Type, error) {
if depth > 32 {
return nil, errors.New("type nesting exceeds 32")
}
switch e := e.(type) {
case *ast.Ident:
if t, ok := scalarTypes[e.Name]; ok {
return t, nil
}
if t, ok := primitiveTypes[e.Name]; ok {
return t, nil
}
return resolve(e.Name, depth+1)
case *ast.StarExpr:
if _, double := e.X.(*ast.StarExpr); double {
return nil, errors.New("multiple pointer levels unsupported")
}
t, err := expression(e.X, depth+1)
if err != nil {
return nil, err
}
return reflect.PointerTo(t), nil
case *ast.ArrayType:
if e.Len != nil {
return nil, errors.New("use slices, not fixed arrays")
}
t, err := expression(e.Elt, depth+1)
if err != nil {
return nil, err
}
return reflect.SliceOf(t), nil
case *ast.MapType:
key, ok := e.Key.(*ast.Ident)
if !ok || key.Name != "string" {
return nil, errors.New("map keys must be string")
}
t, err := expression(e.Value, depth+1)
if err != nil {
return nil, err
}
return reflect.MapOf(reflect.TypeOf(""), t), nil
default:
return nil, errors.New("unsupported type expression")
}
}
resolve = func(name string, depth int) (reflect.Type, error) {
if depth > 32 {
return nil, errors.New("type nesting exceeds 32")
}
if t, ok := resolved[name]; ok {
return t, nil
}
if resolving[name] {
return nil, fmt.Errorf("recursive type %s is unsupported", name)
}
typ, ok := definitions[name]
if !ok {
return nil, fmt.Errorf("unknown type %s", name)
}
resolving[name] = true
defer delete(resolving, name)
var fields []reflect.StructField
seen := map[string]bool{}
for _, f := range typ.Fields {
if !exportedName.MatchString(f.Name) || seen[f.Name] {
return nil, fmt.Errorf("%s: invalid or duplicate field %s", name, f.Name)
}
seen[f.Name] = true
expr, err := goparser.ParseExpr(f.Type)
if err != nil {
return nil, err
}
t, err := expression(expr, depth+1)
if err != nil {
return nil, fmt.Errorf("%s.%s: %w", name, f.Name, err)
}
fields = append(fields, reflect.StructField{Name: f.Name, Type: t, Tag: reflect.StructTag(fieldTag(f))})
}
t := reflect.StructOf(fields)
resolved[name] = t
return t, nil
}
c := &Compiled{Spec: spec, Bindings: map[string]*contract.Binder{}}
for _, t := range spec.Types {
rt, err := resolve(t.Name, 0)
if err != nil {
return nil, err
}
b, err := contract.Describe(rt, scalarDefs...)
if err != nil {
return nil, fmt.Errorf("%s: %w", t.Position, err)
}
c.Bindings[t.Name] = b
}
api, err := openapi.New(spec.Title, spec.Version)
if err != nil {
return nil, err
}
handlers, paths, shapes, groups := map[string]bool{}, map[string]bool{}, map[string]string{}, map[string]bool{}
middlewareSymbols := map[string]string{}
for routeIndex, r := range spec.Routes {
if !goName.MatchString(r.Handler) {
return nil, fmt.Errorf("%s: invalid handler name", r.Position)
}
if strings.EqualFold(r.Handler, "service") || strings.EqualFold(r.Handler, "logic") {
return nil, errors.New("handler name conflicts with the Logic constructor file")
}
if r.Group == "" {
return nil, errors.New("group required")
}
for _, part := range strings.Split(r.Group, "/") {
if !lowerName.MatchString(part) || gotoken.Lookup(part).IsKeyword() {
return nil, fmt.Errorf("invalid lowerCamelCase group %q", r.Group)
}
}
h := r.Group + "/" + strings.ToLower(r.Handler)
if handlers[h] {
return nil, fmt.Errorf("duplicate handler %s in group %s", r.Handler, r.Group)
}
handlers[h] = true
groups[r.Group] = true
mseen := map[string]bool{}
for _, m := range r.Middleware {
if !goName.MatchString(m) || mseen[m] {
return nil, fmt.Errorf("invalid or duplicate middleware %q", m)
}
mseen[m] = true
if strings.EqualFold(m, "defaults") || strings.EqualFold(m, "registryGen") {
return nil, fmt.Errorf("reserved middleware name %q", m)
}
if prior, ok := middlewareSymbols[strings.ToLower(m)]; ok && prior != m {
return nil, fmt.Errorf("middleware names %s and %s collide", prior, m)
}
middlewareSymbols[strings.ToLower(m)] = m
}
shape, err := routeShape(r.Path)
if err != nil {
return nil, fmt.Errorf("%s: %w", r.Position, err)
}
for _, prior := range spec.Routes {
if prior.Method != r.Method || prior.Path == r.Path {
continue
}
if conflictPrefix(prior.Path, r.Path) {
return nil, fmt.Errorf("conflicting parameter names at a shared route prefix: %s and %s", prior.Path, r.Path)
}
}
if prior, exists := shapes[shape]; exists && prior != r.Path {
return nil, fmt.Errorf("ambiguous route parameter names: %s and %s", prior, r.Path)
}
shapes[shape] = r.Path
pathKey := r.Method + " " + shape
if paths[pathKey] {
return nil, fmt.Errorf("duplicate route %s", pathKey)
}
paths[pathKey] = true
if r.Method != strings.ToLower(r.Method) {
return nil, errors.New("HTTP methods must be lowercase")
}
if r.Status != 200 && r.Status != 201 && r.Status != 202 && r.Status != 204 {
return nil, errors.New("@status supports 200, 201, 202 or 204")
}
if !r.Manual && r.Produces == "" && (r.Status == 204) != (r.Response == "") {
return nil, errors.New("204 requires no response; other success statuses require a response type")
}
if r.Method == "head" && r.Response != "" {
return nil, errors.New("HEAD must not declare a response body")
}
if err := validateRouteExtensions(r); err != nil {
return nil, fmt.Errorf("%s: %w", r.Position, err)
}
effectivePolicy := contract.MergePolicy(spec.RequestPolicy, r.RequestPolicy)
if (r.Method == "get" || r.Method == "head") && effectivePolicy.Body != "" && effectivePolicy.Body != "auto" && effectivePolicy.Body != "none" {
return nil, errors.New("GET/HEAD cannot inherit a JSON body policy; override body with auto or none")
}
op := openapi.Operation{Method: r.Method, Path: r.Path, OperationID: operationID(r), Summary: r.Summary, Responses: map[int]openapi.Response{}}
if r.Request != "" {
b, ok := c.Bindings[r.Request]
if !ok {
return nil, fmt.Errorf("unknown request %s", r.Request)
}
r.BoundMultipart = b.HasMultipart() && !r.CustomBinding && !r.Manual
spec.Routes[routeIndex].BoundMultipart = r.BoundMultipart
if b.HasMultipart() && r.BodyMode != "multipart" {
return nil, errors.New("multipart fields require @body (mode: multipart)")
}
op.Parameters = b.Parameters()
op.Request = b.BodySchema()
if op.Request != nil && (effectivePolicy.Body == "none" || effectivePolicy.Body == "emptyObject") {
return nil, errors.New("request policy conflicts with JSON fields")
}
if op.Request != nil && (r.BodyMode == "raw" || r.BodyMode == "multipart") {
return nil, errors.New("raw/multipart request DTOs may declare only path/form/header fields; use the generated body stream for content")
}
if op.Request != nil && (r.Method == "get" || r.Method == "head") {
return nil, errors.New("GET/HEAD requests cannot declare JSON bodies; use form/path/header tags")
}
}
var response *openapi.Schema
if r.Response != "" {
s, err := responseDataSchema(r.Response, c.Bindings)
if err != nil {
return nil, err
}
if r.ResponseFormat == "raw" {
response = s
} else {
response, err = openapi.EnvelopeWith(s, responseConfig(spec, r))
if err != nil {
return nil, err
}
}
}
op.Responses[r.Status] = openapi.Response{Description: "Success", Schema: response}
errorSchema, err := openapi.EnvelopeWith(nil, responseConfig(spec, r))
if err != nil {
return nil, err
}
for _, code := range []int{400, 413, 415, 500, 501} {
op.Responses[code] = openapi.Response{Description: "Error", Schema: errorSchema}
}
if err = api.Handle(discardRoutes{}, op, func(*httpx.Context) error { return nil }); err != nil {
return nil, fmt.Errorf("%s: %w", r.Position, err)
}
}
for group := range groups {
c.Groups = append(c.Groups, group)
}
sort.Strings(c.Groups)
c.Document, err = api.JSON()
if err != nil {
return nil, err
}
// Decorate the document with explicit auth/error contracts. Credential
// validation remains in the linked application middleware.
var doc map[string]any
decoder := json.NewDecoder(bytes.NewReader(c.Document))
decoder.UseNumber()
if err = decoder.Decode(&doc); err != nil {
return nil, err
}
decorateDocument(doc, spec, c.Bindings)
compactDocument(doc)
c.Document, err = json.MarshalIndent(doc, "", " ")
return c, err
}
type discardRoutes struct{}
func (discardRoutes) Handle(string, string, httpx.Handler, ...httpx.Middleware) {}
func operationID(r Route) string {
return strings.ReplaceAll(r.Group, "/", "_") + "_" + upper(r.Handler)
}
func upper(s string) string { return strings.ToUpper(s[:1]) + s[1:] }
func lower(s string) string { return strings.ToLower(s[:1]) + s[1:] }
func routeShape(path string) (string, error) {
if path == "/" {
return path, nil
}
if !strings.HasPrefix(path, "/") {
return "", errors.New("route must start with /")
}
parts := strings.Split(path[1:], "/")
seen := map[string]bool{}
for i, part := range parts {
if strings.HasPrefix(part, ":") {
name := part[1:]
if !lowerName.MatchString(name) || seen[name] {
return "", errors.New("invalid/duplicate path parameter")
}
seen[name] = true
parts[i] = ":"
} else if !staticSegment.MatchString(part) || part == "." || part == ".." {
return "", errors.New("invalid route segment")
}
}
return "/" + strings.Join(parts, "/"), nil
}
func conflictPrefix(left, right string) bool {
a, b := strings.Split(left, "/"), strings.Split(right, "/")
for i := 0; i < len(a) && i < len(b); i++ {
if strings.HasPrefix(a[i], ":") && strings.HasPrefix(b[i], ":") {
if a[i] != b[i] {
return true
}
continue
}
if a[i] != b[i] {
return false
}
}
return false
}

View File

@@ -0,0 +1,102 @@
package apigen
import (
"fmt"
"reflect"
"strings"
)
// expandFields copies definitions rather than embedding Go structs. Binding,
// validation, DTOs, snapshots and OpenAPI all consume the same flattened fields.
func expandFields(spec *Spec) error {
definitions := map[string]Type{}
for _, t := range spec.Types {
if _, exists := definitions[t.Name]; exists {
return fmt.Errorf("%s: duplicate type %s", t.Position, t.Name)
}
definitions[t.Name] = t
}
expanded := map[string]Type{}
heights := map[string]int{}
visiting := map[string]bool{}
total := 0
var expand func(string, int) (Type, error)
expand = func(name string, depth int) (Type, error) {
if t, ok := expanded[name]; ok {
if depth+heights[name] > 32 {
return Type{}, fmt.Errorf("field composition exceeds 32 levels at %s", name)
}
return t, nil
}
if visiting[name] {
return Type{}, fmt.Errorf("field composition cycle at %s", name)
}
if depth > 32 {
return Type{}, fmt.Errorf("field composition exceeds 32 levels at %s", name)
}
t, ok := definitions[name]
if !ok {
return Type{}, fmt.Errorf("unknown composed object type %s", name)
}
visiting[name] = true
defer delete(visiting, name)
fields := []Field{}
height := 0
seen, wire := map[string]bool{}, map[string]bool{}
for _, f := range t.Fields {
add := []Field{f}
if f.Type == "" {
child, err := expand(f.Name, depth+1)
if err != nil {
return Type{}, fmt.Errorf("%s.%s: %w", name, f.Name, err)
}
add = child.Fields
if heights[f.Name]+1 > height {
height = heights[f.Name] + 1
}
if height > 32 {
return Type{}, fmt.Errorf("field composition exceeds 32 levels at %s", name)
}
}
for _, field := range add {
if seen[field.Name] {
return Type{}, fmt.Errorf("%s: conflicting composed field %s", name, field.Name)
}
seen[field.Name] = true
for _, source := range []string{"json", "form", "path", "header", "multipart", "multipartJSON", "file"} {
tag := strings.Split(reflect.StructTag(field.Tag).Get(source), ",")[0]
if tag == "" || tag == "-" {
continue
}
if source == "header" {
tag = strings.ToLower(tag)
}
key := source + ":" + tag
if wire[key] {
return Type{}, fmt.Errorf("%s: conflicting composed field tag %s", name, key)
}
wire[key] = true
}
fields = append(fields, field)
total++
if len(fields) > 4096 || total > 65536 {
return Type{}, fmt.Errorf("field composition expansion limit exceeded")
}
}
}
t.Fields = fields
expanded[name] = t
heights[name] = height
return t, nil
}
result := make([]Type, 0, len(spec.Types))
for _, t := range spec.Types {
out, err := expand(t.Name, 0)
if err != nil {
return err
}
result = append(result, out)
}
spec.Types = result
return nil
}

View File

@@ -0,0 +1,163 @@
package apigen
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
func TestFieldComposition(t *testing.T) {
source := readFile(t, "testdata/composition/service.api")
spec, err := parseText(t, source)
if err != nil {
t.Fatal(err)
}
first, err := Compile(spec)
if err != nil {
t.Fatal(err)
}
second, err := Compile(spec)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(first.Document, second.Document) || len(spec.Types[1].Fields) != 1 {
t.Fatal("composition mutates input")
}
for _, name := range []string{"EnterpriseRequest", "TenantRequest", "AdminRequest"} {
schema := first.Bindings[name].BodySchema()
raw, _ := json.Marshal(schema)
if !bytes.Contains(raw, []byte("Employee display name.")) || bytes.Contains(raw, []byte("SalaryFields")) {
t.Fatalf("flattened schema/comment missing: %s", raw)
}
}
cases := []struct{ name, decl, want string }{
{"unknown", "type Req {\n Missing\n}", "unknown composed"},
{"cycle", "type Req {\n Other\n}\ntype Other {\n Req\n}", "cycle"},
{"conflict", "type Base {\n Name string `json:\"name\"`\n}\ntype Req {\n Base\n Name string `json:\"other\"`\n}", "conflicting"},
{"wireConflict", "type Base {\n Name string `json:\"name\"`\n}\ntype Req {\n Base\n Other string `json:\"name\"`\n}", "conflicting"},
{"tagged", "type Req {\n Other `json:\"other\"`\n}", "cannot carry"},
{"diamond", "type Base {\n Name string `json:\"name\"`\n}\ntype Left {\n Base\n}\ntype Right {\n Base\n}\ntype Req {\n Left\n Right\n}", "conflicting"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s, e := parseText(t, tc.decl+"\nservice demo{\n@handler post\npost / (Req)\n}")
if e == nil {
_, e = Compile(s)
}
if e == nil || !strings.Contains(e.Error(), tc.want) {
t.Fatal(e)
}
})
}
// Imports can contain reusable definitions and retain the existing root policy.
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "base.api"), "type Base {\n Name string `json:\"name\"`\n}\n")
writeFile(t, filepath.Join(dir, "service.api"), "import \"base.api\"\ntype Req {\n Base\n}\nservice demo{\n@handler post\npost / (Req)\n}")
if _, err := FormatProject(filepath.Join(dir, "service.api"), false); err != nil {
t.Fatal(err)
}
if _, err := FormatProject(filepath.Join(dir, "service.api"), true); err != nil {
t.Fatal(err)
}
}
func TestRepositoryGenerationOptions(t *testing.T) {
dir := filepath.Join(t.TempDir(), "app")
if _, err := New(Options{Dir: dir, Module: "example.com/repository", FrameworkDir: frameworkRoot(t)}); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, "internal/repository")); !os.IsNotExist(err) {
t.Fatal("default creates repository")
}
o := Options{Dir: dir, API: filepath.Join(dir, "api/service.api")}
writeFile(t, filepath.Join(dir, generatorConfigFile), `{"repository":"group","middleware":"factories"}`)
if _, err := Generate(o); err != nil {
t.Fatal(err)
}
repo := filepath.Join(dir, "internal/repository/greet/repository.go")
original := readFile(t, repo) + "\n// business SQL belongs here\n"
writeFile(t, repo, original)
writeFile(t, filepath.Join(dir, generatorConfigFile), `{"repository":"none","middleware":"factories"}`)
if _, err := Generate(o); err != nil {
t.Fatal(err)
}
o.Check = true
if _, err := Generate(o); err != nil {
t.Fatal(err)
}
if readFile(t, repo) != original {
t.Fatal("repository removed or overwritten")
}
for _, raw := range []string{`{"repository":"oops"}`, `{"middleware":"oops"}`, `{"unknown":true}`, `{"repository":"none","repository":"group"}`} {
writeFile(t, filepath.Join(dir, generatorConfigFile), raw)
before := snapshot(t, dir)
if _, err := Generate(o); err == nil {
t.Fatal("accepted", raw)
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("invalid config wrote files")
}
}
}
func TestCompositionExpansionBound(t *testing.T) {
var source strings.Builder
source.WriteString("type Base {\n Value string `json:\"value\"`\n}\n")
last := "Base"
for i := 0; i < 35; i++ {
name := fmt.Sprintf("Level%d", i)
fmt.Fprintf(&source, "type %s {\n %s\n}\n", name, last)
last = name
}
// Reverse order forces traversal instead of hitting pre-expanded definitions.
spec, err := parseText(t, source.String()+"service demo{\n@handler post\npost / ("+last+")\n}")
if err != nil {
t.Fatal(err)
}
for i, j := 0, len(spec.Types)-1; i < j; i, j = i+1, j-1 {
spec.Types[i], spec.Types[j] = spec.Types[j], spec.Types[i]
}
if _, err = Compile(spec); err == nil {
t.Fatal("unbounded composition accepted")
}
}
func TestGenerationConfigMissingAndLegacy(t *testing.T) {
dir := filepath.Join(t.TempDir(), "app")
if _, err := New(Options{Dir: dir, Module: "example.com/config", FrameworkDir: frameworkRoot(t)}); err != nil {
t.Fatal(err)
}
o := Options{Dir: dir, API: filepath.Join(dir, "api/service.api")}
if err := os.Remove(filepath.Join(dir, generatorConfigFile)); err != nil {
t.Fatal(err)
}
before := snapshot(t, dir)
if _, err := Generate(o); err == nil || !strings.Contains(err.Error(), "restore") {
t.Fatal(err)
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("missing config changed project")
}
// A genuine old manifest has never tracked a generator config.
var old manifest
if err := json.Unmarshal([]byte(readFile(t, filepath.Join(dir, manifestPath))), &old); err != nil {
t.Fatal(err)
}
old.GeneratorConfig = false
raw, _ := json.Marshal(old)
writeFile(t, filepath.Join(dir, manifestPath), string(raw))
if _, err := Generate(o); err != nil {
t.Fatal(err)
}
cfg, exists, err := readGeneratorConfig(dir)
if err != nil || !exists || cfg.Middleware != "handlers" || cfg.Repository != "none" {
t.Fatal(cfg, exists, err)
}
if !strings.Contains(readFile(t, filepath.Join(dir, "internal/svc/runtimeGen.go")), "middleware.Defaults()") {
t.Fatal("legacy registration replaced")
}
}

View File

@@ -0,0 +1,136 @@
package apigen
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func TestGeneratedConfigurationConsumer(t *testing.T) {
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") != "1" {
t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1")
}
dir := filepath.Join(t.TempDir(), "app")
api := filepath.Join(t.TempDir(), "service.api")
writeFile(t, api, "service configuration {\n@handler ping\nget /ping\n}\n")
o := Options{API: api, Dir: dir, Module: "example.com/configuration", FrameworkDir: frameworkRoot(t)}
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
main := readFile(t, filepath.Join(dir, "main.go"))
// The business only changes fields/defaults/validation, not the loading flow.
writeFile(t, filepath.Join(dir, "internal/config/config.go"), `package config
import("git.yuntuo.hk/k2safe/go-omni/config";"git.yuntuo.hk/k2safe/go-omni/logx")
type Config struct { Server config.Config `+"`json:\"server\"`"+`;Limit int `+"`json:\"limit\"`"+` }
func Default()Config{ c:=config.Default();c.Log.File="./var/log/app.log";c.Log.Rotation=logx.RotationConfig{Enabled:true,MaxSizeMB:10};return Config{Server:c,Limit:10} }
func(c Config)Validate()error{if err:=c.Server.Validate();err!=nil{return config.AtField("server",err)};if c.Limit<0{return config.Invalid("limit","must not be negative")};return nil}
`)
writeFile(t, filepath.Join(dir, "internal/config/loader.go"), `package config
import omniConfig "git.yuntuo.hk/k2safe/go-omni/config"
func NewLoader()(*omniConfig.Loader,error){return omniConfig.NewLoader(omniConfig.WithDecoder("custom",omniConfig.JSONDecoder{}))}
`)
svc := filepath.Join(dir, "internal/svc/serviceContext.go")
writeFile(t, svc, strings.Replace(readFile(t, svc), "return nil", "panic(\"dependencies must not initialize on invalid config\")", 1))
writeFile(t, filepath.Join(dir, "consumer_test.go"), readFile(t, "testdata/configuration/consumer_test.go"))
runConsumerGo(t, dir, "mod", "tidy")
runConsumerGo(t, dir, "test", "-race", "-count=1", "./...")
runConsumerGo(t, dir, "build", ".")
o.API = filepath.Join(dir, "api/service.api")
before := snapshot(t, dir)
for _, mode := range []string{"dry", "generate", "check"} {
o.DryRun = mode == "dry"
o.Check = mode == "check"
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("business files overwritten")
}
if main != readFile(t, filepath.Join(dir, "main.go")) {
t.Fatal("main changed")
}
load := filepath.Join(dir, "internal/config/loadGen.go")
original := readFile(t, load)
writeFile(t, load, original+"\n// drift\n")
if err := generateManagedTestProject(t, o); err == nil {
t.Fatal("loadGen drift accepted")
}
writeFile(t, load, original)
if os.Getenv("GO_OMNI_RELEASE_CLI") != "" {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "go", "list", "-m", "-json", frameworkModule)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off")
data, err := cmd.CombinedOutput()
if err != nil {
t.Fatal(err, string(data))
}
var dependency struct {
Version, Sum string
Replace any
}
if err = json.Unmarshal(data, &dependency); err != nil {
t.Fatal(err)
}
if dependency.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || dependency.Replace != nil || dependency.Sum == "" {
t.Fatal("not a remote consumer")
}
t.Logf("remote framework %s checksum %s; no local replace", dependency.Version, dependency.Sum)
}
}
func TestConfigLoadMigrationGuard(t *testing.T) {
dir := filepath.Join(t.TempDir(), "app")
o := Options{Dir: dir, Module: "example.com/upgrade", FrameworkDir: frameworkRoot(t)}
if _, err := New(o); err != nil {
t.Fatal(err)
}
o.API = filepath.Join(dir, "api/service.api")
matches, err := filepath.Glob(filepath.Join(dir, "api/*.api"))
if err != nil || len(matches) != 1 {
t.Fatal(matches, err)
}
o.API = matches[0]
writeFile(t, filepath.Join(dir, "internal/config/legacy.go"), "package config\nfunc Load(path string)(Config,error){return Default(),nil}\n")
before := snapshot(t, dir)
for _, mode := range []string{"dry", "generate", "check"} {
o.DryRun = mode == "dry"
o.Check = mode == "check"
_, err := Generate(o)
if err == nil || !strings.Contains(err.Error(), "CONFIG.md") {
t.Fatal("missing migration diagnostic", err)
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("migration guard changed files")
}
}
}
func TestRemovedEnvironmentTagsRejectBeforeGeneration(t *testing.T) {
dir := filepath.Join(t.TempDir(), "app")
o := Options{Dir: dir, Module: "example.com/migrate", FrameworkDir: frameworkRoot(t)}
if _, err := New(o); err != nil {
t.Fatal(err)
}
o.API = filepath.Join(dir, "api/service.api")
path := filepath.Join(dir, "internal/config/config.go")
source := readFile(t, path)
writeFile(t, path, strings.Replace(source, "json:\"server\"", "json:\"server\" env:\"OLD_SERVER\"", 1))
before := snapshot(t, dir)
for _, mode := range []string{"dry", "generate", "check"} {
o.DryRun = mode == "dry"
o.Check = mode == "check"
if _, err := Generate(o); err == nil || !strings.Contains(err.Error(), "env/envDefault") {
t.Fatal(err)
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("migration rejection wrote files")
}
}
}

View File

@@ -0,0 +1,245 @@
package apigen
import (
"fmt"
"go/ast"
goparser "go/parser"
"reflect"
"sort"
"strings"
)
// Diagnostic points to preserved project code that needs attention. Review
// notices describe contract changes; errors describe definite source problems.
type Diagnostic struct {
Severity, Code, Path, Operation, Message string
Line int
Before, After string
Details []string
}
// Snapshots describe the last successfully generated contract, never business
// source or absolute paths. They are persisted in the ownership transaction.
type contractSnapshot struct {
Routes map[string]routeContract `json:"routes"`
}
type routeContract struct {
Method string `json:"method"`
Path string `json:"path"`
Group string `json:"group"`
Handler string `json:"handler"`
Signature string `json:"signature"`
Request string `json:"request,omitempty"`
Response string `json:"response,omitempty"`
BodyMode string `json:"bodyMode,omitempty"`
Produces string `json:"produces,omitempty"`
MaxBodyBytes int64 `json:"maxBodyBytes,omitempty"`
Manual bool `json:"manual,omitempty"`
CustomBinding bool `json:"customBinding,omitempty"`
Types map[string]typeContract `json:"types,omitempty"`
}
type typeContract struct {
Fields []contractField `json:"fields,omitempty"`
Scalar string `json:"scalar,omitempty"`
}
type contractField struct {
Name string `json:"name"`
Type string `json:"type"`
Tag string `json:"tag"`
}
func snapshotContract(spec *Spec) *contractSnapshot {
definitions := map[string]typeContract{}
for _, t := range spec.Types {
d := typeContract{}
for _, f := range t.Fields {
d.Fields = append(d.Fields, contractField{f.Name, f.Type, f.Tag})
}
definitions[t.Name] = d
}
for _, s := range spec.Scalars {
definitions[s.Name] = typeContract{Scalar: fmt.Sprintf("%s.%s (jsonInput=%s, jsonOutput=%s, text=%t)", s.GoImport, s.GoType, s.JSONInput, s.JSONOutput, s.Text)}
}
snapshot := &contractSnapshot{Routes: map[string]routeContract{}}
for _, r := range spec.Routes {
dependencies := map[string]typeContract{}
var visit func(string)
visit = func(name string) {
d, ok := definitions[name]
if !ok {
return
}
if _, exists := dependencies[name]; exists {
return
}
dependencies[name] = d
for _, f := range d.Fields {
expr, err := goparser.ParseExpr(f.Type)
if err != nil {
continue
} // Compile already validates expressions.
ast.Inspect(expr, func(n ast.Node) bool {
if id, ok := n.(*ast.Ident); ok {
visit(id.Name)
}
return true
})
}
}
visit(r.Request)
_, responseName := responseShape(r.Response)
visit(responseName)
op := routeContract{
Method: strings.ToUpper(r.Method), Path: r.Path, Group: r.Group, Handler: r.Handler,
Request: r.Request, Response: r.Response, BodyMode: r.BodyMode, Produces: r.Produces,
MaxBodyBytes: r.MaxBodyBytes, Manual: r.Manual, CustomBinding: r.CustomBinding, Types: dependencies,
}
if !r.Manual {
op.Signature = expectedLogicSignature(r)
}
snapshot.Routes[r.Group+"/"+lower(r.Handler)] = op
}
return snapshot
}
func (r routeContract) file() string {
if r.Manual {
return "internal/handler/" + r.Group + "/" + lower(r.Handler) + "Manual.go"
}
return "internal/logic/" + r.Group + "/" + lower(r.Handler) + ".go"
}
func (r routeContract) operation() string { return r.Method + " " + r.Path }
func compareContracts(old, next *contractSnapshot) []Diagnostic {
if old == nil {
return nil
}
var diagnostics []Diagnostic
for key, previous := range old.Routes {
current, exists := next.Routes[key]
if !exists {
diagnostics = append(diagnostics, Diagnostic{Severity: "review", Code: "routeRemoved", Path: previous.file(), Operation: previous.operation(), Message: "route removed or moved; its handwritten implementation is preserved; review obsolete code and types"})
continue
}
d := Diagnostic{Severity: "review", Code: "contractChanged", Path: current.file(), Operation: current.operation(), Message: "contract changed; review the preserved implementation"}
if previous.Signature != current.Signature {
d.Before, d.After = previous.Signature, current.Signature
d.Details = append(d.Details, "Logic call signature changed")
}
if previous.Method != current.Method || previous.Path != current.Path {
d.Details = append(d.Details, previous.operation()+" -> "+current.operation())
}
if previous.Manual != current.Manual || previous.CustomBinding != current.CustomBinding || previous.BodyMode != current.BodyMode || previous.MaxBodyBytes != current.MaxBodyBytes || previous.Produces != current.Produces {
d.Details = append(d.Details, fmt.Sprintf("HTTP access: manual=%t customBinding=%t body=%s maxBytes=%d produces=%q -> manual=%t customBinding=%t body=%s maxBytes=%d produces=%q", previous.Manual, previous.CustomBinding, previous.BodyMode, previous.MaxBodyBytes, previous.Produces, current.Manual, current.CustomBinding, current.BodyMode, current.MaxBodyBytes, current.Produces))
}
d.Details = append(d.Details, changedTypes(previous.Types, current.Types)...)
if len(d.Details) > 0 {
diagnostics = append(diagnostics, d)
}
}
return diagnostics
}
func changedTypes(old, next map[string]typeContract) []string {
names := map[string]bool{}
for name := range old {
names[name] = true
}
for name := range next {
names[name] = true
}
var details []string
for name := range names {
a, existed := old[name]
b, exists := next[name]
switch {
case !existed:
details = append(details, "DTO dependency added: "+name)
case !exists:
details = append(details, "DTO dependency removed: "+name)
case a.Scalar != b.Scalar:
details = append(details, fmt.Sprintf("scalar %s: %s -> %s", name, a.Scalar, b.Scalar))
case !reflect.DeepEqual(a.Fields, b.Fields):
oldFields, newFields := map[string]contractField{}, map[string]contractField{}
for _, f := range a.Fields {
oldFields[f.Name] = f
}
for _, f := range b.Fields {
newFields[f.Name] = f
}
before := len(details)
for field, f := range oldFields {
if n, ok := newFields[field]; !ok {
details = append(details, fmt.Sprintf("DTO %s.%s removed", name, field))
} else if f != n {
details = append(details, fmt.Sprintf("DTO %s.%s: %s `%s` -> %s `%s`", name, field, f.Type, f.Tag, n.Type, n.Tag))
}
}
for field := range newFields {
if _, ok := oldFields[field]; !ok {
details = append(details, fmt.Sprintf("DTO %s.%s added", name, field))
}
}
if len(details) == before {
details = append(details, "DTO "+name+" field order changed; review positional struct literals")
}
}
}
sort.Strings(details)
return details
}
func sortDiagnostics(diagnostics []Diagnostic) {
sort.Slice(diagnostics, func(i, j int) bool {
a, b := diagnostics[i], diagnostics[j]
if a.Path != b.Path {
return a.Path < b.Path
}
if a.Line != b.Line {
return a.Line < b.Line
}
if a.Code != b.Code {
return a.Code < b.Code
}
return a.Operation < b.Operation
})
}
func diagnosticErrors(diagnostics []Diagnostic) int {
count := 0
for _, d := range diagnostics {
if d.Severity == "error" {
count++
}
}
return count
}
func expectedLogicSignature(r Route) string {
params := []string{"context.Context"}
if r.Request != "" {
params = append(params, "*types."+r.Request)
}
if streamsBody(r) {
params = append(params, "*httpx.Body")
}
results := []string{"error"}
if r.Produces != "" {
results = []string{"*httpx.Stream", "error"}
} else if result := responseGoType(r.Response); result != "" {
results = []string{result, "error"}
}
return displaySignature(upper(r.Handler), params, results)
}
func displaySignature(name string, params, results []string) string {
result := strings.Join(results, ", ")
if len(results) != 1 {
result = "(" + result + ")"
}
return name + "(" + strings.Join(params, ", ") + ") " + result
}

View File

@@ -0,0 +1,308 @@
package apigen
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
func findDiagnostic(t *testing.T, out Output, code string) Diagnostic {
t.Helper()
for _, d := range out.Diagnostics {
if d.Code == code {
return d
}
}
t.Fatalf("missing %s diagnostic: %+v", code, out.Diagnostics)
return Diagnostic{}
}
func TestContractChangesPersistUntilLogicFixed(t *testing.T) {
dir := filepath.Join(t.TempDir(), "app")
out, err := New(Options{Dir: dir, Module: "example.com/changes", FrameworkDir: frameworkRoot(t)})
if err != nil || len(out.Diagnostics) > 0 {
t.Fatalf("initial generation: %v %+v", err, out.Diagnostics)
}
o := Options{Dir: dir, API: filepath.Join(dir, "api/service.api")}
logicPath := filepath.Join(dir, "internal/logic/greet/greet.go")
logicBefore := readFile(t, logicPath)
writeFile(t, o.API, strings.ReplaceAll(readFile(t, o.API), "GreetReq", "HelloReq"))
before := snapshot(t, dir)
for _, dryRun := range []bool{true, false} {
o.DryRun, o.Check = dryRun, !dryRun
out, err = Generate(o)
if dryRun && err != nil {
t.Fatal(err)
}
if !dryRun && err == nil {
t.Fatal("check accepted stale project")
}
d := findDiagnostic(t, out, "logicSignature")
if d.Line <= 0 || d.Path != "internal/logic/greet/greet.go" || !strings.Contains(d.After, "*types.HelloReq") || !strings.Contains(d.Before, "*types.GreetReq") {
t.Fatalf("imprecise diagnosis: %+v", d)
}
findDiagnostic(t, out, "contractChanged")
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("preview/check changed files")
}
}
o.DryRun, o.Check = false, false
out, err = Generate(o)
if err != nil {
t.Fatal(err)
}
findDiagnostic(t, out, "logicSignature")
if readFile(t, logicPath) != logicBefore {
t.Fatal("business source overwritten")
}
o.Check = true
out, err = Generate(o)
if err == nil || strings.Contains(err.Error(), "stale") || len(out.Changes) != 0 {
t.Fatalf("signature drift must remain after generation: %v %+v", err, out.Changes)
}
findDiagnostic(t, out, "logicSignature")
// Method location and import/parameter names are project-owned. Find the
// method in a different file, honoring aliases and named result variables.
writeFile(t, logicPath, "package greet\n// business split into visitor.go\n")
writeFile(t, filepath.Join(dir, "internal/logic/greet/visitor.go"), `package greet
import (ctx "context"; dto "example.com/changes/internal/types")
func(s *Logic)Greet(input ctx.Context, request *dto.HelloReq)(output *dto.GreetResp, problem error){
return &dto.GreetResp{Message:request.Name},nil
}
`)
out, err = Generate(o)
if err != nil || len(out.Diagnostics) > 0 {
t.Fatalf("compatible business method rejected: %v %+v", err, out.Diagnostics)
}
before = snapshot(t, dir)
o.Check = false
if _, err = Generate(o); err != nil {
t.Fatal(err)
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("stable generation changed files")
}
// Field review notices should point to the actual moved business method.
writeFile(t, o.API, strings.Replace(readFile(t, o.API), `max=80`, `max=90`, 1))
o.DryRun = true
out, err = Generate(o)
if err != nil {
t.Fatal(err)
}
d := findDiagnostic(t, out, "contractChanged")
if d.Path != "internal/logic/greet/visitor.go" || d.Line == 0 {
t.Fatalf("wrong implementation location: %+v", d)
}
o.DryRun = false
if _, err = Generate(o); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "go", "test", "-mod=mod", "./...")
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off")
if data, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("fixed generated consumer: %v\n%s", err, data)
}
}
func TestContractChangesReachNestedDTOs(t *testing.T) {
source := `syntax = "v1"
type Address { Street string ` + "`json:\"street\"`" + ` }
type Request { Address Address ` + "`json:\"address\"`" + ` }
type Reply { Accepted bool ` + "`json:\"accepted\"`" + ` }
@server(group: orders)
service orders-api {
@handler create
post /orders (Request) returns (Reply)
@handler ping
get /ping
}
`
root := t.TempDir()
api := filepath.Join(root, "source/service.api")
writeFile(t, api, source)
dir := filepath.Join(root, "app")
if _, err := Generate(Options{API: api, Dir: dir, Module: "example.com/nested", FrameworkDir: frameworkRoot(t)}); err != nil {
t.Fatal(err)
}
o := Options{API: filepath.Join(dir, "api/service.api"), Dir: dir, DryRun: true}
writeFile(t, o.API, strings.Replace(source, "Street string", "Street int64", 1))
out, err := Generate(o)
if err != nil {
t.Fatal(err)
}
if len(out.Diagnostics) != 1 {
t.Fatalf("unrelated routes should not be warned: %+v", out.Diagnostics)
}
d := findDiagnostic(t, out, "contractChanged")
if d.Path != "internal/logic/orders/create.go" || !strings.Contains(strings.Join(d.Details, "\n"), "Address.Street: string") {
t.Fatalf("nested field not explained: %+v", d)
}
// Import/file/comment/formatting changes are not semantic DTO changes.
writeFile(t, o.API, source+"\n// a harmless documentation edit\n")
out, err = Generate(o)
if err != nil || len(out.Diagnostics) > 0 {
t.Fatalf("comment caused false diagnostic: %v %+v", err, out.Diagnostics)
}
// Removal preserves the old implementation and explicitly points it out.
writeFile(t, o.API, strings.Replace(source, " @handler create\n post /orders (Request) returns (Reply)\n", "", 1))
out, err = Generate(o)
if err != nil {
t.Fatal(err)
}
d = findDiagnostic(t, out, "routeRemoved")
if d.Path != "internal/logic/orders/create.go" {
t.Fatal(d)
}
}
func TestContractSnapshotSemanticDetails(t *testing.T) {
base := &Spec{Types: []Type{{Name: "Req", Fields: []Field{{Name: "ID", Type: "ID", Tag: `form:"id"`}}}}, Scalars: []ScalarDefinition{{Name: "ID", GoImport: "example.com/id", GoType: "Value", JSONInput: "string", JSONOutput: "string"}}, Routes: []Route{{Group: "files", Handler: "send", Method: "post", Path: "/file", Request: "Req", BodyMode: "raw", MaxBodyBytes: 64, Produces: "text/plain"}}}
old := snapshotContract(base)
base.Scalars[0].Text = true
base.Routes[0].BodyMode = "multipart"
base.Routes[0].Produces = "application/octet-stream"
base.Routes[0].MaxBodyBytes = 128
base.Routes[0].Path = "/files"
diagnostics := compareContracts(old, snapshotContract(base))
if len(diagnostics) != 1 {
t.Fatal(diagnostics)
}
details := strings.Join(diagnostics[0].Details, "\n")
for _, want := range []string{"scalar ID", "body=raw", "body=multipart", "POST /file -> POST /files", "maxBytes=128"} {
if !strings.Contains(details, want) {
t.Fatal("missing", want, details)
}
}
base.Routes[0].Produces = ""
diagnostics = compareContracts(old, snapshotContract(base))
if !strings.Contains(diagnostics[0].Before, "*httpx.Stream") || !strings.HasSuffix(diagnostics[0].After, ") error") {
t.Fatal(diagnostics)
}
}
func TestLogicDiagnosticsSourceBoundaries(t *testing.T) {
for _, tc := range []struct{ name, source, code, severity string }{
{"wrongResult", `package greet; import "context"; func(s *Logic)Greet(context.Context) error{return nil}`, "logicSignature", "error"},
{"syntax", `package greet; func(s *Logic)Greet( {`, "logicSyntax", "error"},
{"alias", `package greet; import("context";"example.com/test/internal/types");type Input = types.GreetReq; func(s *Logic)Greet(context.Context,*Input)(*types.GreetResp,error){return nil,nil}`, "logicUnchecked", "review"},
{"conditional", "//go:build projectTag\n\npackage greet\n", "logicUnchecked", "review"},
{"missing", "package greet\n", "logicNotFound", "review"},
} {
t.Run(tc.name, func(t *testing.T) {
dir := filepath.Join(t.TempDir(), "app")
if _, err := New(Options{Dir: dir, Module: "example.com/test", FrameworkDir: frameworkRoot(t)}); err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(dir, "internal/logic/greet/greet.go"), tc.source)
before := snapshot(t, dir)
out, err := Generate(Options{Dir: dir, API: filepath.Join(dir, "api/service.api"), Check: true})
d := findDiagnostic(t, out, tc.code)
if d.Severity != tc.severity || (tc.severity == "error") != (err != nil) {
t.Fatalf("unexpected check result: %v %+v", err, d)
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("analysis modified business source")
}
})
}
}
func TestContractSnapshotBootstrapAndDeterminism(t *testing.T) {
dir := filepath.Join(t.TempDir(), "app")
if _, err := New(Options{Dir: dir, Module: "example.com/test", FrameworkDir: frameworkRoot(t)}); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, manifestPath)
var previous manifest
if err := json.Unmarshal([]byte(readFile(t, path)), &previous); err != nil {
t.Fatal(err)
}
previous.Contract = nil
data, _ := json.MarshalIndent(previous, "", " ")
writeFile(t, path, string(data)+"\n")
o := Options{Dir: dir, API: filepath.Join(dir, "api/service.api"), DryRun: true}
a, err := Generate(o)
if err != nil {
t.Fatal(err)
}
b, err := Generate(o)
if err != nil || !reflect.DeepEqual(a, b) {
t.Fatal("non-deterministic preview", err)
}
findDiagnostic(t, a, "contractBaseline")
o.DryRun = false
if _, err = Generate(o); err != nil {
t.Fatal(err)
}
o.Check = true
out, err := Generate(o)
if err != nil || len(out.Diagnostics) > 0 {
t.Fatal(err, out.Diagnostics)
}
}
func TestLogicResponseAndBodySignatureChanges(t *testing.T) {
for _, tc := range []struct {
name, after string
change func(string) string
}{
{"responseType", "*types.HelloResp", func(s string) string { return strings.ReplaceAll(s, "GreetResp", "HelloResp") }},
{"bodyParameter", "*httpx.Body", func(s string) string {
return strings.Replace(s, " get /greet", " @body (mode: raw; maxBytes: 64)\n post /greet", 1)
}},
{"streamResult", "*httpx.Stream", func(s string) string {
return strings.Replace(strings.Replace(s, " returns (GreetResp)", "", 1), " get /greet", " @produces \"text/plain\"\n get /greet", 1)
}},
} {
t.Run(tc.name, func(t *testing.T) {
dir := filepath.Join(t.TempDir(), "app")
if _, err := New(Options{Dir: dir, Module: "example.com/test", FrameworkDir: frameworkRoot(t)}); err != nil {
t.Fatal(err)
}
o := Options{Dir: dir, API: filepath.Join(dir, "api/service.api"), DryRun: true}
writeFile(t, o.API, tc.change(readFile(t, o.API)))
out, err := Generate(o)
if err != nil {
t.Fatal(err)
}
d := findDiagnostic(t, out, "logicSignature")
if !strings.Contains(d.After, tc.after) {
t.Fatal(d)
}
})
}
}
func TestLogicDuplicateAndTestFiles(t *testing.T) {
dir := filepath.Join(t.TempDir(), "app")
if _, err := New(Options{Dir: dir, Module: "example.com/test", FrameworkDir: frameworkRoot(t)}); err != nil {
t.Fatal(err)
}
source := readFile(t, filepath.Join(dir, "internal/logic/greet/greet.go"))
// String literals mentioning build constraints do not make a file conditional.
writeFile(t, filepath.Join(dir, "internal/logic/greet/greet.go"), source+"\nconst diagnosticText=\"//go:build special\"\n")
writeFile(t, filepath.Join(dir, "internal/logic/greet/fixture_test.go"), source)
o := Options{Dir: dir, API: filepath.Join(dir, "api/service.api"), Check: true}
out, err := Generate(o)
if err != nil || len(out.Diagnostics) > 0 {
t.Fatal(err, out.Diagnostics)
}
writeFile(t, filepath.Join(dir, "internal/logic/greet/duplicate.go"), source)
out, err = Generate(o)
if err == nil {
t.Fatal("duplicate method passed check")
}
d := findDiagnostic(t, out, "logicDuplicate")
if len(d.Details) != 2 || !strings.Contains(d.Details[0], "duplicate.go:") {
t.Fatal(d)
}
}

View File

@@ -0,0 +1,289 @@
package apigen
import (
"encoding/json"
"fmt"
"git.yuntuo.hk/k2safe/go-omni/contract"
"net/http"
"regexp"
"sort"
"strings"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/openapi"
"git.yuntuo.hk/k2safe/go-omni/response"
)
var errorCode = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`)
var credentialName = regexp.MustCompile("^[!#$%&'*+.^_`|~0-9A-Za-z-]+$")
// prepareMetadata returns a detached spec; repeated compilation must not prepend
// authentication middleware more than once or mutate the parser's output.
func prepareMetadata(original *Spec) (*Spec, error) {
spec := *original
spec.Routes = append([]Route(nil), original.Routes...)
spec.Errors = append([]ErrorDefinition(nil), original.Errors...)
if err := validateResponses(&spec); err != nil {
return nil, err
}
definitions := map[string]ErrorDefinition{}
codes, symbols := map[string]bool{}, map[string]bool{"Catalog": true}
for _, e := range spec.Errors {
if !exportedName.MatchString(e.Name) || !apierrors.ValidErrorStatus(e.Status) || strings.TrimSpace(e.Message) == "" {
return nil, fmt.Errorf("%s: error requires UpperCamelCase name, a final body-bearing status (200..599 except 204/205/304) and a nonempty public message", e.Position)
}
if e.Catalog == "" && !errorCode.MatchString(e.Code) {
return nil, fmt.Errorf("%s: legacy error declaration requires UPPER_SNAKE_CASE code", e.Position)
}
if codes[e.Code] {
return nil, fmt.Errorf("%s: duplicate error code %s", e.Position, e.Code)
}
for _, symbol := range []string{e.Name, "Code" + e.Name} {
if symbols[symbol] {
return nil, fmt.Errorf("%s: duplicate error symbol %s", e.Position, symbol)
}
symbols[symbol] = true
}
codes[e.Code] = true
definitions[e.Name] = e
}
registry, err := apierrors.NewRegistry(errorCatalog(&spec, nil))
if err != nil {
return nil, err
}
normalized := registry.Catalog()
for i := range spec.Errors {
spec.Errors[i].BusinessCode = normalized.Definitions[spec.Errors[i].Code].Code
spec.Errors[i].Params = normalized.Definitions[spec.Errors[i].Code].Params
spec.Errors[i].Translation = normalized.Definitions[spec.Errors[i].Code].Translation
}
if err := validateErrorPolicies(&spec); err != nil {
return nil, err
}
auth := map[string]AuthDefinition{}
for _, a := range spec.Auth {
if !exportedName.MatchString(a.Name) || !goName.MatchString(a.Middleware) || strings.EqualFold(a.Middleware, "defaults") || strings.EqualFold(a.Middleware, "registryGen") {
return nil, fmt.Errorf("%s: auth requires an UpperCamelCase name and a valid named middleware", a.Position)
}
if _, ok := auth[a.Name]; ok {
return nil, fmt.Errorf("%s: duplicate auth %s", a.Position, a.Name)
}
switch a.Type {
case "bearer":
if a.In != "" || a.KeyName != "" {
return nil, fmt.Errorf("%s: bearer auth does not accept in/name", a.Position)
}
case "apiKey":
if (a.In != "header" && a.In != "cookie") || !credentialName.MatchString(a.KeyName) || a.BearerFormat != "" {
return nil, fmt.Errorf("%s: apiKey requires in header/cookie and a valid name; bearerFormat is not supported", a.Position)
}
if a.In == "header" && (strings.EqualFold(a.KeyName, "Authorization") || strings.EqualFold(a.KeyName, "Cookie")) {
return nil, fmt.Errorf("%s: use bearer for Authorization or in cookie for a session", a.Position)
}
default:
return nil, fmt.Errorf("%s: auth type must be bearer or apiKey", a.Position)
}
auth[a.Name] = a
}
for i := range spec.Routes {
r := &spec.Routes[i]
r.Middleware = append([]string(nil), r.Middleware...)
if r.Auth != "" && r.Auth != "none" {
a, ok := auth[r.Auth]
if !ok {
return nil, fmt.Errorf("%s: unknown auth %s; declare auth %s (...) first", r.Position, r.Auth, r.Auth)
}
for _, m := range r.Middleware {
if strings.EqualFold(m, a.Middleware) {
return nil, fmt.Errorf("%s: auth %s already installs %s; remove it from middleware", r.Position, r.Auth, a.Middleware)
}
}
r.Middleware = append([]string{a.Middleware}, r.Middleware...)
}
seen := map[string]bool{}
for _, name := range r.Errors {
if _, ok := definitions[name]; !ok {
return nil, fmt.Errorf("%s: unknown error %s; declare it in errorCatalog or error (...)", r.Position, name)
}
if seen[name] {
return nil, fmt.Errorf("%s: duplicate error %s", r.Position, name)
}
seen[name] = true
}
seen = map[string]bool{}
for _, tag := range r.Tags {
if strings.TrimSpace(tag) == "" || seen[tag] {
return nil, fmt.Errorf("%s: @doc tags must be nonempty and unique", r.Position)
}
seen[tag] = true
}
}
return &spec, nil
}
func decorateDocument(doc map[string]any, spec *Spec, bindings map[string]*contract.Binder) {
doc["x-omni-contract-sources"] = sourceMetadata(spec)
if spec.DefaultResponse != "" {
doc["x-omni-default-response"] = spec.DefaultResponse
}
if spec.Description != "" {
doc["info"].(map[string]any)["description"] = spec.Description
}
schemes := map[string]any{}
for _, a := range spec.Auth {
scheme := map[string]any{}
if a.Type == "bearer" {
scheme["type"], scheme["scheme"] = "http", "bearer"
if a.BearerFormat != "" {
scheme["bearerFormat"] = a.BearerFormat
}
} else {
scheme["type"], scheme["in"], scheme["name"] = "apiKey", a.In, a.KeyName
}
if a.Description != "" {
scheme["description"] = a.Description
}
schemes[a.Name] = scheme
}
doc["components"].(map[string]any)["securitySchemes"] = schemes
definitions := map[string]ErrorDefinition{}
declaredRegistry := apierrors.MustRegistry(errorCatalog(spec, nil))
var documentRegistry *apierrors.Registry
if spec.DocumentCatalog != nil {
documentRegistry = apierrors.MustRegistry(*spec.DocumentCatalog)
doc["x-omni-app-error-catalog"] = documentRegistry.Catalog()
}
for _, e := range spec.Errors {
definitions[e.Name] = e
}
if len(spec.Errors) > 0 {
doc["x-omni-errors"] = spec.Errors
}
for _, r := range spec.Routes {
registry := declaredRegistry
policy := response.MustNew(responseConfig(spec, r))
cfg := policy.Config()
parts := strings.Split(r.Path, "/")
for i, part := range parts {
if strings.HasPrefix(part, ":") {
parts[i] = "{" + part[1:] + "}"
}
}
op := doc["paths"].(map[string]any)[strings.Join(parts, "/")].(map[string]any)[r.Method].(map[string]any)
requestRoute := r
requestRoute.RequestPolicy = contract.MergePolicy(spec.RequestPolicy, r.RequestPolicy)
decorateRequestOptions(op, requestRoute)
if b := bindings[r.Request]; b != nil && b.HasMultipart() {
encoding := map[string]any{}
for _, name := range b.MultipartJSONFields() {
encoding[name] = map[string]any{"contentType": "application/json"}
}
op["requestBody"] = map[string]any{"required": true, "content": map[string]any{"multipart/form-data": map[string]any{"schema": b.MultipartSchema(), "encoding": encoding}}}
}
if r.ResponseFormat != "" {
op["x-omni-response"] = r.ResponseFormat
}
if len(r.Middleware) > 0 {
op["x-omni-middleware"] = r.Middleware
}
if r.Auth != "" && r.Auth != "none" {
op["security"] = []map[string][]string{{r.Auth: {}}}
} else {
op["security"] = []any{}
}
if r.Description != "" {
op["description"] = r.Description
}
if len(r.Tags) > 0 {
op["tags"] = r.Tags
} else {
op["tags"] = []string{r.Group}
}
if r.Deprecated {
op["deprecated"] = true
}
responses := op["responses"].(map[string]any)
if r.Auth != "" && r.Auth != "none" {
responses["401"] = genericErrorResponse("Authentication required", cfg)
}
grouped := map[int][]ErrorDefinition{}
for _, name := range r.Errors {
e := definitions[name]
grouped[e.Status] = append(grouped[e.Status], e)
}
if len(r.Errors) == 0 && documentRegistry != nil {
registry = documentRegistry
op["x-omni-error-catalog-scope"] = "app"
for key, definition := range documentRouteCatalog(spec, r).Definitions {
grouped[definition.Status] = append(grouped[definition.Status], ErrorDefinition{Name: key, Code: key, Status: definition.Status, Message: definition.Message, BusinessCode: definition.Code, Params: definition.Params})
}
}
if len(r.Errors) > 0 {
op["x-omni-errors"] = r.Errors
}
for status, errs := range grouped {
key := fmt.Sprint(status)
response, exists := responses[key].(map[string]any)
if !exists {
response = genericErrorResponse(http.StatusText(status), cfg)
responses[key] = response
}
content, ok := response["content"].(map[string]any)
if !ok {
content = map[string]any{}
response["content"] = content
}
media, hasJSON := content["application/json"].(map[string]any)
if !hasJSON {
media = map[string]any{}
content["application/json"] = media
}
errorMedia := genericErrorResponse("", cfg)["content"].(map[string]any)["application/json"].(map[string]any)
if exists && status < 400 && media["schema"] != nil {
media["schema"] = map[string]any{"anyOf": []any{media["schema"], errorMedia["schema"]}}
} else if media["schema"] == nil {
media["schema"] = errorMedia["schema"]
}
examples := map[string]any{}
codes := []any{}
seenCodes := map[string]bool{}
description := []string{}
if exists {
if text, ok := response["description"].(string); ok && text != "" {
description = append(description, text)
}
}
sort.Slice(errs, func(i, j int) bool { return errs[i].Name < errs[j].Name })
for _, e := range errs {
result := responseResult(e)
result.Message = registry.Example(e.Code).Message
value := policy.Value(result)
examples[e.Name] = map[string]any{"summary": result.Message, "value": value}
codeKey := fmt.Sprint(value[cfg.CodeField])
if !seenCodes[codeKey] {
codes = append(codes, value[cfg.CodeField])
seenCodes[codeKey] = true
}
description = append(description, e.Code+": "+e.Message)
}
media["examples"] = examples
if !exists && cfg.Body == nil && cfg.CodeField != "-" {
media["schema"].(map[string]any)["properties"].(map[string]any)[cfg.CodeField].(map[string]any)["enum"] = codes
}
response["description"] = strings.Join(description, "\n")
}
}
}
func responseResult(e ErrorDefinition) response.Result {
return response.Result{Status: e.Status, Code: e.Code, Message: e.Message, BusinessCode: e.BusinessCode, Failed: true, RequestID: "exampleRequest"}
}
func genericErrorResponse(description string, cfg response.Config) map[string]any {
if description == "" {
description = "Error"
}
schema, _ := openapi.EnvelopeWith(nil, cfg)
data, _ := json.Marshal(schema)
var value map[string]any
_ = json.Unmarshal(data, &value)
return map[string]any{"description": description, "content": map[string]any{"application/json": map[string]any{"schema": value}}}
}

View File

@@ -0,0 +1,118 @@
package apigen
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func runConsumerGo(t *testing.T, dir string, args ...string) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "go", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("go %v: %v\n%s", args, err, out)
}
}
func TestCopyxThreeEndpointConsumer(t *testing.T) {
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") != "1" {
t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1")
}
dir := filepath.Join(t.TempDir(), "app")
o := Options{API: filepath.Join(frameworkRoot(t), "cmd/go-omni/apigen/testdata/composition/service.api"), Dir: dir, Module: "example.com/composition", FrameworkDir: frameworkRoot(t)}
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
for path, fixture := range map[string]string{
"internal/amount/money.go": "amount.go", "internal/domain/employee.go": "domain.go", "internal/convert/fields.go": "converters.go", "internal/convert/copy.go": "copy.go",
"internal/middleware/dependencies.go": "dependencies.go", "internal/middleware/accessFactory.go": "accessFactory.go", "internal/svc/client.go": "client.go", "consumer_test.go": "consumer_test.go",
} {
writeFile(t, filepath.Join(dir, path), readFile(t, "testdata/composition/"+fixture))
}
svcFile := filepath.Join(dir, "internal/svc/serviceContext.go")
source := strings.Replace(readFile(t, svcFile), "type ServiceContext struct {", "type ServiceContext struct {\n client *Client", 1)
source = strings.Replace(source, "return nil", "return c.initClient(ctx)", 1)
writeFile(t, svcFile, source)
configFile := filepath.Join(dir, "internal/config/config.go")
writeFile(t, configFile, strings.Replace(readFile(t, configFile), "type Config struct {", "type Config struct {\n FactoryMode string `json:\"factoryMode\"`", 1))
for _, group := range []string{"Enterprise", "Tenant", "Admin"} {
text := fmt.Sprintf(`package %s
import("context";"example.com/composition/internal/types";"example.com/composition/internal/convert";"example.com/composition/internal/domain")
func(s *Logic)Save(ctx context.Context,req *types.%sRequest)(*types.EmployeeResponse,error){
input,err:=convert.Employee(*req);if err!=nil{return nil,err}
result,err:=convert.Response(domain.Result{Employee:input,ID:9007199254740993});if err!=nil{return nil,err};return &result,nil
}
`, strings.ToLower(group), group)
writeFile(t, filepath.Join(dir, "internal/logic", strings.ToLower(group), "save.go"), text)
}
runConsumerGo(t, dir, "mod", "tidy")
runConsumerGo(t, dir, "test", "-race", "-count=1", "./...")
o.API = filepath.Join(dir, "api/service.api")
before := snapshot(t, dir)
o.DryRun = true
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
o.DryRun = false
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
o.Check = true
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("generation changed business files")
}
if _, err := os.Stat(filepath.Join(dir, "internal/mapping")); !os.IsNotExist(err) {
t.Fatal("mapping directory generated")
}
// Adding a common field requires one contract edit and one independent
// business field. Three endpoint converters need no new rules.
contract := readFile(t, o.API)
contract = strings.Replace(contract, "type SalaryFields {", "type SalaryFields {\n Note string `json:\"note,optional\"`", 1)
writeFile(t, o.API, contract)
domainPath := filepath.Join(dir, "internal/domain/employee.go")
writeFile(t, domainPath, strings.Replace(readFile(t, domainPath), "type Employee struct {", "type Employee struct {\n Note string", 1))
o.Check = false
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
o.Check = true
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
runConsumerGo(t, dir, "test", "-race", "-count=1", "./...")
runConsumerGo(t, dir, "build", ".")
if os.Getenv("GO_OMNI_RELEASE_CLI") != "" {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "go", "list", "-m", "-json", frameworkModule)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off")
data, err := cmd.CombinedOutput()
if err != nil {
t.Fatal(err, string(data))
}
var dependency struct {
Version, Sum string
Replace any
}
if err = json.Unmarshal(data, &dependency); err != nil {
t.Fatal(err)
}
if dependency.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || dependency.Replace != nil || dependency.Sum == "" {
t.Fatal("not a fixed remote consumer")
}
t.Logf("remote framework %s checksum %s; no local replace", dependency.Version, dependency.Sum)
}
}

View File

@@ -0,0 +1,59 @@
package apigen
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"testing"
)
func TestGeneratedCORSConsumer(t *testing.T) {
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") != "1" {
t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1")
}
dir := filepath.Join(t.TempDir(), "app")
api := filepath.Join(t.TempDir(), "service.api")
writeFile(t, api, "service corsdemo {\n@handler ping\nget /ping\n}\n")
o := Options{API: api, Dir: dir, Module: "example.com/corsdemo", FrameworkDir: frameworkRoot(t)}
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(dir, "consumer_test.go"), readFile(t, "testdata/cors/consumer_test.go"))
writeFile(t, filepath.Join(dir, "etc/config.yaml"), "server:\n log:\n output: discard\n cors:\n enabled: true\n allowedOrigins: [https://web.example]\n allowedMethods: [GET, POST, PUT]\n allowedHeaders: [Authorization, Content-Type]\n maxAge: 10m\n")
runConsumerGo(t, dir, "mod", "tidy")
runConsumerGo(t, dir, "test", "-race", "-count=1", "./...")
runConsumerGo(t, dir, "build", ".")
o.API = filepath.Join(dir, "api/service.api")
before := snapshot(t, dir)
for _, mode := range []string{"dry", "generate", "check"} {
o.DryRun = mode == "dry"
o.Check = mode == "check"
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("regeneration changed project files")
}
if os.Getenv("GO_OMNI_RELEASE_CLI") != "" {
cmd := exec.Command("go", "list", "-m", "-json", frameworkModule)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatal(err, string(out))
}
var dependency struct {
Version, Sum string
Replace any
}
if err := json.Unmarshal(out, &dependency); err != nil {
t.Fatal(err)
}
if dependency.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || dependency.Replace != nil || dependency.Sum == "" {
t.Fatal("not a fixed remote consumer")
}
t.Logf("remote framework %s checksum %s; no local replace", dependency.Version, dependency.Sum)
}
}

View File

@@ -0,0 +1,171 @@
package apigen
import (
"fmt"
"strconv"
"strings"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
)
// ErrorDefinition declares a public, stable error. Generated constructors return
// a fresh error each time so WithCause cannot mutate a shared singleton.
type ErrorDefinition struct {
Name string `json:"name"`
BusinessCode any `json:"businessCode,omitempty"`
Params []apierrors.Parameter `json:"params,omitempty"`
Translation *apierrors.Translation `json:"translation,omitempty"`
Catalog string `json:"catalog,omitempty"`
Status int `json:"status"`
Code string `json:"code"`
Message string `json:"message"`
Position string `json:"-"`
}
type AuthDefinition struct {
Name, Type, In, KeyName, Middleware, Description, BearerFormat, Position string
}
func (p *parser) parseErrors() error {
p.next()
block := p.peek() == "("
if block {
p.next()
p.skip()
}
for {
pos := p.tokens[p.at].position
name, err := p.ident()
if err != nil {
return err
}
status, err := strconv.Atoi(p.next().text)
if err != nil {
return p.err("error HTTP status must be an integer")
}
code, err := p.quoted()
if err != nil {
return err
}
message, err := p.quoted()
if err != nil {
return err
}
p.spec.Errors = append(p.spec.Errors, ErrorDefinition{Name: name, Status: status, Code: code, Message: message, Position: pos})
if p.peek() != "\n" && p.peek() != ";" && !(block && p.peek() == ")") && p.peek() != "" {
return p.err("expected end of error declaration")
}
if !block {
return nil
}
p.skip()
if p.peek() == ")" {
p.next()
return nil
}
}
}
func (p *parser) parseAuth() error {
pos := p.next().position
name, err := p.ident()
if err != nil {
return err
}
p.skip()
props, err := p.metadata()
if err != nil {
return err
}
a := AuthDefinition{Name: name, Position: pos}
for key, value := range props {
switch key {
case "type":
a.Type = value
case "in":
a.In = value
case "name":
a.KeyName = value
case "middleware":
a.Middleware = value
case "description":
a.Description = value
case "bearerFormat":
a.BearerFormat = value
default:
return fmt.Errorf("%s: unsupported auth property %q", pos, key)
}
}
p.spec.Auth = append(p.spec.Auth, a)
return nil
}
func (p *parser) names() ([]string, error) {
var names []string
for {
name, err := p.ident()
if err != nil {
return nil, err
}
for _, prior := range names {
if prior == name {
return nil, p.err("duplicate name " + name)
}
}
names = append(names, name)
if p.peek() != "," {
return names, nil
}
p.next()
}
}
func commaList(value string) []string {
if value == "" {
return nil
}
result := strings.Split(value, ",")
for i := range result {
result[i] = strings.TrimSpace(result[i])
}
return result
}
func mergeNames(base, extra []string) []string {
result := append([]string(nil), base...)
for _, name := range extra {
found := false
for _, prior := range result {
found = found || prior == name
}
if !found {
result = append(result, name)
}
}
return result
}
func (p *parser) parseDoc(route *Route) error {
if p.peek() != "(" {
value, err := p.quoted()
route.Summary = value
return err
}
pos := p.tokens[p.at].position
props, err := p.metadata()
if err != nil {
return err
}
for key, value := range props {
switch key {
case "summary":
route.Summary = value
case "description":
route.Description = value
case "tags":
route.Tags = commaList(value)
case "deprecated":
if value != "true" && value != "false" {
return fmt.Errorf("%s: deprecated must be true or false", pos)
}
route.Deprecated = value == "true"
default:
return fmt.Errorf("%s: unsupported @doc property %q", pos, key)
}
}
return nil
}

View File

@@ -0,0 +1,86 @@
package apigen
import "encoding/json"
// compactDocument only replaces standard OpenAPI Reference Object positions.
// Payloads (including examples containing a literal "$ref") are never walked.
// Mixed HTTP 200 responses retain both success and error alternatives.
func compactDocument(doc map[string]any) {
type slot struct {
parent map[string]any
key string
}
components := doc["components"].(map[string]any)
var responses, schemas, examples []slot
for _, path := range doc["paths"].(map[string]any) {
for _, value := range path.(map[string]any) {
op, ok := value.(map[string]any)
if !ok {
continue
}
rs, _ := op["responses"].(map[string]any)
for status := range rs {
responses = append(responses, slot{rs, status})
}
}
}
factor := func(kind string, slots []slot) {
counts := map[string]int{}
for _, s := range slots {
raw, _ := json.Marshal(s.parent[s.key])
counts[string(raw)]++
}
shared, _ := components[kind].(map[string]any)
if shared == nil {
shared = map[string]any{}
}
for _, s := range slots {
raw, _ := json.Marshal(s.parent[s.key])
count := counts[string(raw)]
if count < 2 {
continue
}
name := "Omni_" + digest(raw)
ref := map[string]any{"$ref": "#/components/" + kind + "/" + name}
refJSON, _ := json.Marshal(ref)
// Keep small objects inline if components would increase size.
if (count-1)*len(raw) <= count*len(refJSON)+len(name)+4 {
continue
}
shared[name] = s.parent[s.key]
s.parent[s.key] = map[string]any{"$ref": "#/components/" + kind + "/" + name}
}
if len(shared) > 0 {
components[kind] = shared
}
}
// Share whole responses before counting inner objects. Otherwise a catalog
// copied across 100 routes can create hundreds of unnecessary components
// that only one shared response ultimately uses.
factor("responses", responses)
collectMedia := func(value any) {
resp := value.(map[string]any)
content, _ := resp["content"].(map[string]any)
for _, value := range content {
media := value.(map[string]any)
if media["schema"] != nil {
schemas = append(schemas, slot{media, "schema"})
}
es, _ := media["examples"].(map[string]any)
for name := range es {
examples = append(examples, slot{es, name})
}
}
}
for _, s := range responses {
collectMedia(s.parent[s.key])
}
sharedResponses, _ := components["responses"].(map[string]any)
for _, resp := range sharedResponses {
collectMedia(resp)
}
factor("examples", examples)
factor("schemas", schemas)
}

View File

@@ -0,0 +1,107 @@
package apigen
import (
"encoding/json"
"fmt"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"strings"
"testing"
)
func TestSharedErrorDocumentReferences(t *testing.T) {
source := `@app(defaultResponse: Common)
response Common(codeType:int32;successCode:0;errorCode:9999;messageField:msg;dataMode:always)
service demo {
`
for i := 0; i < 16; i++ {
kind := "string"
if i%2 == 0 {
kind = "int64"
}
source += fmt.Sprintf("@handler list%d\nget /list%d returns ([]%s)\n", i, i, kind)
}
source += "}\n"
spec, err := parseText(t, source)
if err != nil {
t.Fatal(err)
}
catalog := apierrors.Catalog{DefaultStatus: 400, Definitions: map[string]apierrors.Definition{}}
for i := 0; i < 80; i++ {
status := 400
if i%2 == 0 {
status = 200
}
catalog.Definitions[fmt.Sprintf("ERROR_%d", i)] = apierrors.Definition{Status: status, Code: 400000 + i, Message: strings.Repeat("说明", 40) + "{label}不能超过{max}个字符", Params: []apierrors.Parameter{{Name: "label", Type: "string", Example: "企业名称"}, {Name: "max", Type: "int", Example: 100}}}
}
spec.DocumentCatalog = &catalog
compiled, err := Compile(spec)
if err != nil {
t.Fatal(err)
}
var raw map[string]any
if err = json.Unmarshal(compiled.Document, &raw); err != nil {
t.Fatal(err)
}
components := raw["components"].(map[string]any)
for _, kind := range []string{"responses", "examples"} {
if len(components[kind].(map[string]any)) == 0 {
t.Fatal("missing shared", kind)
}
}
expanded := document(t, compiled)
for i := 0; i < 16; i++ {
responses := operation(t, expanded, fmt.Sprintf("/list%d", i), "get")["responses"].(map[string]any)
for _, status := range []string{"200", "400"} {
media := responses[status].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)
if len(media["examples"].(map[string]any)) != 40 {
t.Fatal("examples lost")
}
if status == "200" {
choices := media["schema"].(map[string]any)["anyOf"].([]any)
if len(choices) != 2 || choices[0].(map[string]any)["properties"].(map[string]any)["data"].(map[string]any)["type"] != "array" {
t.Fatal("200 success shape lost")
}
}
for _, example := range media["examples"].(map[string]any) {
value := example.(map[string]any)["value"].(map[string]any)
if _, ok := value["data"]; !ok || value["data"] != nil || !strings.Contains(value["msg"].(string), "企业名称不能超过100个字符") {
t.Fatal(value)
}
}
}
}
expandedBytes, _ := json.MarshalIndent(expanded, "", " ")
if len(compiled.Document)*5 > len(expandedBytes) {
t.Fatalf("poor dedup: %d vs %d", len(compiled.Document), len(expandedBytes))
}
again, err := Compile(spec)
if err != nil || string(again.Document) != string(compiled.Document) {
t.Fatal("non-deterministic document", err)
}
before, _ := json.Marshal(raw)
compactDocument(raw)
data, _ := json.Marshal(raw)
if string(data) != string(before) {
t.Fatal("compaction not idempotent")
}
}
func TestSharedSchemasAndLiteralExampleReference(t *testing.T) {
doc := map[string]any{"components": map[string]any{}, "paths": map[string]any{}}
paths := doc["paths"].(map[string]any)
for _, name := range []string{"first", "second", "third"} {
schema := map[string]any{"type": "object", "description": strings.Repeat("schema description ", 40)}
example := map[string]any{"summary": strings.Repeat("example ", 50), "value": map[string]any{"$ref": "literal business field"}}
paths["/"+name] = map[string]any{"get": map[string]any{"responses": map[string]any{"200": map[string]any{"description": name, "content": map[string]any{"application/json": map[string]any{"schema": schema, "examples": map[string]any{"same": example}}}}}}}
}
compactDocument(doc)
components := doc["components"].(map[string]any)
if len(components["schemas"].(map[string]any)) != 1 {
t.Fatal("schema not shared")
}
for _, example := range components["examples"].(map[string]any) {
if example.(map[string]any)["value"].(map[string]any)["$ref"] != "literal business field" {
t.Fatal("payload reference interpreted")
}
}
}

View File

@@ -0,0 +1,127 @@
package apigen
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestFieldDocumentationAndFormat(t *testing.T) {
source := "// 项目说明\n type Req{\n// 状态\n State int `json:\"state,options=0|1\" example:\"1\" enumDescriptions:\"[\\\"禁用\\\",\\\"启用\\\"]\"` // 状态值\n /* 第一行\n 第二行 */\nName string `json:\"name\" example:\"企业名称\"`\n}\nservice demo{\n @handler echo\npost /echo (Req) returns (Req)\n}\n"
formatted, err := Format([]byte(source))
if err != nil {
t.Fatal(err)
}
again, err := Format(formatted)
if err != nil || !bytes.Equal(formatted, again) {
t.Fatal("format is not idempotent", err, string(again))
}
if !bytes.Contains(formatted, []byte(" // 状态\n State")) {
t.Fatal(string(formatted))
}
spec, err := parseText(t, string(formatted))
if err != nil {
t.Fatal(err)
}
c, err := Compile(spec)
if err != nil {
t.Fatal(err)
}
if spec.Types[0].Fields[0].Description != "状态\n状态值" || spec.Types[0].Fields[1].Description != "第一行\n第二行" {
t.Fatalf("%+v", spec.Types[0].Fields)
}
schema, err := c.Bindings["Req"].ResponseSchema()
if err != nil {
t.Fatal(err)
}
state := schema.Properties["state"]
if state.Description != "状态\n状态值" || len(state.EnumDescriptions) != 2 || state.Example != json.Number("1") {
t.Fatalf("%+v", state)
}
dir := t.TempDir()
entry := filepath.Join(dir, "demo.api")
writeFile(t, entry, source)
if _, err = FormatProject(entry, true); err == nil {
t.Fatal("check accepted drift")
}
if readFile(t, entry) != source {
t.Fatal("check wrote source")
}
if _, err = FormatProject(entry, false); err != nil {
t.Fatal(err)
}
if _, err = FormatProject(entry, true); err != nil {
t.Fatal(err)
}
project := filepath.Join(t.TempDir(), "demo")
if _, err = Generate(Options{API: entry, Dir: project, Module: "example.com/docs", FrameworkDir: frameworkRoot(t)}); err != nil {
t.Fatal(err)
}
dto := readFile(t, filepath.Join(project, "internal/types/"+dtoFile("demo.api:1:1")))
if !strings.Contains(dto, "// 状态值") || !strings.Contains(dto, `description:"状态\n状态值"`) {
t.Fatal(dto)
}
// Invalid examples fail before project output is created.
bad := strings.Replace(source, "example:\"1\"", "example:\"9\"", 1)
writeFile(t, entry, bad)
if _, err = FormatProject(entry, false); err == nil {
t.Fatal("invalid enum example accepted")
}
if data, _ := os.ReadFile(entry); string(data) != bad {
t.Fatal("invalid source changed")
}
}
func TestFormatAlignsFieldsAndPreservesTokens(t *testing.T) {
source := []byte("type Req {\n Name string `json:\"name\"` // 名称\n Sequence int64 `json:\"sequence\"` // 编号\n}\n")
out, err := Format(source)
if err != nil {
t.Fatal(err)
}
again, err := Format(out)
if err != nil || !bytes.Equal(out, again) {
t.Fatal("alignment not idempotent", err)
}
lines := strings.Split(string(out), "\n")
if strings.Index(lines[1], "`") != strings.Index(lines[2], "`") || strings.Index(lines[1], "//") != strings.Index(lines[2], "//") {
t.Fatal("fields not aligned", string(out))
}
for _, literal := range []string{"`json:\"name\"`", "`json:\"sequence\"`", "// 名称", "// 编号"} {
if !bytes.Contains(out, []byte(literal)) {
t.Fatal("literal changed", literal)
}
}
}
func TestRequestPolicyCannotCreateGETBody(t *testing.T) {
for _, source := range []string{
"service demo { @handler get\n @request(body: required)\n get /x }",
"@app(request: {\"body\":\"optional\"})\n service demo { @handler get\n get /x }",
} {
spec, err := parseText(t, source)
if err == nil {
_, err = Compile(spec)
}
if err == nil {
t.Fatal("GET body policy accepted")
}
}
}
func TestFormatLiteralPreservation(t *testing.T) {
source := []byte("type Demo {\n Body string `json:\"body\"\ndescription:\"literal space\"`\n}\n")
out, err := Format(source)
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(out, []byte("`json:\"body\"\ndescription:\"literal space\"`")) {
t.Fatal(string(out))
}
for _, src := range []string{"type X {", "}", "info (title:\"unterminated)"} {
if _, err := Format([]byte(src)); err == nil {
t.Fatal("invalid input accepted")
}
}
}

View File

@@ -0,0 +1,60 @@
package apigen
import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
)
func TestConfigurableBuiltinRoutesConsumer(t *testing.T) {
dir := filepath.Join(t.TempDir(), "endpoints")
api := filepath.Join(t.TempDir(), "service.api")
writeFile(t, api, "service healthApi {\n @handler customReady\n get /health/ready\n @handler customDoc\n get /openapi.json\n}\n")
_, err := Generate(Options{Dir: dir, API: api, Module: "example.com/endpoints", FrameworkDir: frameworkRoot(t)})
if err != nil {
t.Fatal("built-in paths must be permitted in API contracts", err)
}
writeFile(t, filepath.Join(dir, "endpoints_test.go"), `package main
import (
"context"
"io"
"net/http/httptest"
"testing"
"strings"
"example.com/endpoints/internal/config"
"example.com/endpoints/internal/svc"
"example.com/endpoints/internal/handler"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
func TestProjectOwnsDisabledBuiltinPaths(t *testing.T){
cfg:=config.Default();cfg.Server.Log.Writer=io.Discard
logger,_:=logx.New(cfg.Server.Log);defer logger.Close()
deps,err:=svc.New(context.Background(),logger,cfg);if err!=nil{t.Fatal(err)};defer deps.Close()
if _,err=handler.NewApp(deps);err==nil || !strings.Contains(err.Error(),"overlaps business"){t.Fatal("expected preflight collision",err)}
deps.Config.Server.Endpoints.Readiness.Enabled=false
deps.Config.Server.Endpoints.OpenAPI.Enabled=false
app,err:=handler.NewApp(deps);if err!=nil{t.Fatal(err)};defer app.Close()
for _,path:=range []string{"/health/ready","/openapi.json"}{
out:=httptest.NewRecorder();app.ServeHTTP(out,httptest.NewRequest("GET",path,nil))
if out.Code!=501{t.Fatal("expected generated business Logic stub",path,out.Code,out.Body.String())}
}
deps.Config.Server.Endpoints.Readiness.Enabled=true
deps.Config.Server.Endpoints.Readiness.Path="/readyz"
deps.Config.Server.Endpoints.OpenAPI.Enabled=true
deps.Config.Server.Endpoints.OpenAPI.Path="/spec.json"
app2,err:=handler.NewApp(deps);if err!=nil{t.Fatal(err)};defer app2.Close()
for _,path:=range []string{"/readyz","/spec.json"}{out:=httptest.NewRecorder();app2.ServeHTTP(out,httptest.NewRequest("GET",path,nil));if out.Code!=200{t.Fatal(path,out.Code)}}
}
`)
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "go", "test", "-race", "-mod=mod", "-count=1", "./...")
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off")
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatal(err, string(output))
}
}

View File

@@ -0,0 +1,120 @@
package apigen
import (
"encoding/json"
"fmt"
"sort"
"strconv"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/response"
)
// A named catalog groups project definitions under one default HTTP status.
// Each JSON object key supplies the generated Go function's name, while key is
// the independent logical identifier and code is the public string/integer.
func (p *parser) parseErrorCatalog() error {
pos := p.next().position
name, err := p.ident()
if err != nil {
return err
}
if !exportedName.MatchString(name) {
return p.err("errorCatalog requires an UpperCamelCase name")
}
for _, prior := range p.spec.ErrorCatalogs {
if prior == name {
return p.err("duplicate errorCatalog " + name)
}
}
p.spec.ErrorCatalogs = append(p.spec.ErrorCatalogs, name)
p.skip()
props, err := p.metadata()
if err != nil {
return err
}
defaultStatus := 400
if value, ok := props["defaultStatus"]; ok {
defaultStatus, err = strconv.Atoi(value)
if err != nil {
return p.err("defaultStatus must be an integer")
}
}
if !apierrors.ValidErrorStatus(defaultStatus) {
return p.err("invalid errorCatalog defaultStatus")
}
for key := range props {
if key != "defaultStatus" && key != "errors" {
return p.err("unsupported errorCatalog property " + key)
}
}
type entry struct {
Key string `json:"key"`
Code any `json:"code"`
Message string `json:"message"`
Status int `json:"status"`
Params []apierrors.Parameter `json:"params"`
Translation *apierrors.Translation `json:"translation"`
}
var entries map[string]entry
if err := apierrors.DecodeCatalogJSON([]byte(props["errors"]), &entries); err != nil {
return fmt.Errorf("%s: catalog %s: %w", pos, name, err)
}
if len(entries) == 0 {
return p.err("errorCatalog requires errors")
}
names := make([]string, 0, len(entries))
for key := range entries {
names = append(names, key)
}
sort.Strings(names)
for _, fn := range names {
e := entries[fn]
if e.Code == nil {
return p.err("errorCatalog error requires code")
}
if e.Status == 0 {
e.Status = defaultStatus
}
p.spec.Errors = append(p.spec.Errors, ErrorDefinition{Name: fn, Status: e.Status, Code: e.Key, Message: e.Message, BusinessCode: e.Code, Params: e.Params, Translation: e.Translation, Catalog: name, Position: pos})
}
return nil
}
func errorCatalog(spec *Spec, names []string) apierrors.Catalog {
c := apierrors.Catalog{Definitions: map[string]apierrors.Definition{}}
selected := map[string]bool{}
for _, n := range names {
selected[n] = true
}
for _, e := range spec.Errors {
if names != nil && !selected[e.Name] {
continue
}
c.Definitions[e.Code] = apierrors.Definition{Status: e.Status, Code: e.BusinessCode, Message: e.Message, Params: e.Params, Translation: e.Translation}
}
return c
}
// Validate the exact catalog/template pair used by each route. Different routes
// can use different code types without forcing a global response representation.
func validateErrorPolicies(spec *Spec) error {
for _, r := range spec.Routes {
f, err := response.New(responseConfig(spec, r))
if err != nil {
return err
}
if _, err = response.WithErrors(errorCatalog(spec, append([]string{}, r.Errors...)), f); err != nil {
return fmt.Errorf("%s: %w", r.Position, err)
}
}
return nil
}
func errorCatalogJSON(spec *Spec, names []string) string {
data, err := json.Marshal(errorCatalog(spec, names))
if err != nil {
panic(err)
}
return string(data)
}

View File

@@ -0,0 +1,222 @@
package apigen
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func TestErrorCatalogDocumentAndValidation(t *testing.T) {
path := filepath.Join(frameworkRoot(t), "examples/contracts/errors.api")
source := readFile(t, path)
spec, err := Parse(path)
if err != nil {
t.Fatal(err)
}
compiled, err := Compile(spec)
if err != nil {
t.Fatal(err)
}
for _, path := range []string{"/token", "/raw"} {
method := "post"
if path == "/raw" {
method = "get"
}
op := operation(t, document(t, compiled), path, method)
response := op["responses"].(map[string]any)["200"].(map[string]any)
media := response["content"].(map[string]any)["application/json"].(map[string]any)
alternatives := media["schema"].(map[string]any)["anyOf"].([]any)
if len(alternatives) != 2 {
t.Fatal("lost success or failure shape")
}
if path == "/raw" {
if alternatives[0].(map[string]any)["properties"].(map[string]any)["token"] == nil {
t.Fatal("raw success wrapped")
}
}
examples := media["examples"].(map[string]any)
if len(examples) != 2 {
t.Fatal(examples)
}
for _, name := range []string{"UsernameInvalid", "PasswordInvalid"} {
value := examples[name].(map[string]any)["value"].(map[string]any)
if value["ok"] != false || value["code"] != float64(400002) || value["msg"] == nil {
t.Fatal(value)
}
}
}
for _, change := range [][2]string{
{`defaultStatus: 200`, `defaultStatus: 204`},
{`"status": 403`, `"status": 304`},
{`"PASSWORD_INVALID"`, `"USERNAME_INVALID"`},
{`"code": 400002`, `"code": 2147483648`},
{`"code": 400002`, `"code": "400002"`},
{`"code": 400002`, `"code": 1.5`},
{`"code": 400002`, `"code": null`},
{`"code": 400002`, `"code": 400002, "code": 400003`},
{`"message": "用户名格式不正确"`, `"message": "用户名格式不正确", "typo": true`},
{`@handler token`, `@errors Unknown\n @handler token`},
{`successCode: 0`, `successCode: 0\n codes: {"USERNAME_INVALID": 999}`},
} {
text := strings.Replace(source, change[0], strings.ReplaceAll(change[1], `\n`, "\n"), 1)
s, err := parseText(t, text)
if err == nil {
_, err = Compile(s)
}
if err == nil {
t.Errorf("accepted %s", change[1])
}
}
// The legacy declaration is also allowed to carry a HTTP 200 failure.
old := strings.Replace(declaredAPI, "EmailTaken 409", "EmailTaken 200", 1)
s, err := parseText(t, old)
if err == nil {
_, err = Compile(s)
}
if err != nil {
t.Fatal(err)
}
}
func TestErrorCatalogConsumer(t *testing.T) {
testCatalogConsumer(t, "errors.api", "errorCatalog")
}
func testCatalogConsumer(t *testing.T, apiFile, fixtureName string, extraFiles ...map[string]string) {
t.Helper()
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") == "" {
t.Skip("consumer integration")
}
root := frameworkRoot(t)
dir := filepath.Join(t.TempDir(), "consumer")
opts := Options{API: filepath.Join(root, "examples/contracts", apiFile), Dir: dir, Module: "example.com/errorCatalog", FrameworkDir: root}
cli := os.Getenv("GO_OMNI_RELEASE_CLI")
generate := func(check bool) {
t.Helper()
if cli == "" {
o := opts
o.Check = check
if _, err := Generate(o); err != nil {
t.Fatal(err)
}
return
}
args := []string{"api", "generate", "--api", opts.API, "--dir", dir, "--module", opts.Module}
if check {
args = append(args, "--check")
}
cmd := exec.Command(cli, args...)
cmd.Dir = filepath.Dir(dir)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("release generate %v\n%s", err, output)
}
}
generate(false)
files := map[string]string{"consumer_test.go": "consumer_test.go"}
for _, extra := range extraFiles {
for target, source := range extra {
files[target] = source
}
}
before := map[string]string{}
for target, source := range files {
fixture := readFile(t, filepath.Join("testdata", fixtureName, source))
writeFile(t, filepath.Join(dir, target), fixture)
before[target] = fixture
}
generate(false)
generate(true)
for target, fixture := range before {
if readFile(t, filepath.Join(dir, target)) != fixture {
t.Fatalf("regeneration overwrote %s", target)
}
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "go", "test", "-race", "-mod=mod", "-count=1", "./...")
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off")
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("consumer %v\n%s", err, output)
}
if cli != "" {
cmd = exec.CommandContext(ctx, "go", "list", "-m", "-json", frameworkModule)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off")
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatal(err, string(output))
}
var module struct {
Version, Sum string
Replace any
}
if err = json.Unmarshal(output, &module); err != nil {
t.Fatal(err)
}
if module.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || module.Replace != nil || module.Sum == "" {
t.Fatal("not a fixed remote consumer")
}
t.Logf("remote framework %s checksum %s; no local replace", module.Version, module.Sum)
}
}
func TestErrorCatalogImportsAndRouteScopes(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "common.api"), `errorCatalog Numeric (
defaultStatus: 200
errors: {"NumberInvalid":{"key":"number.invalid","code":12,"message":"number"}}
)
errorCatalog Text (
defaultStatus: 409
errors: {"TextInvalid":{"key":"textInvalid","code":"000012","message":"text"}}
)
response NumericResponse (
codeType: int32
successCode: 0
errorCode: 9999
)
`)
writeFile(t, filepath.Join(dir, "service.api"), `import "common.api"
@server (
response: NumericResponse
errors: NumberInvalid
)
service scopes-api {
@handler numeric
get /number
}
@server (
errors: TextInvalid
)
service scopes-api {
@handler text
get /text
}
`)
s, err := Parse(filepath.Join(dir, "service.api"))
if err != nil {
t.Fatal(err)
}
compiled, err := Compile(s)
if err != nil {
t.Fatal(err)
}
doc := document(t, compiled)
for _, tc := range []struct {
path, status, name string
code any
}{{"/number", "200", "NumberInvalid", float64(12)}, {"/text", "409", "TextInvalid", "000012"}} {
response := operation(t, doc, tc.path, "get")["responses"].(map[string]any)[tc.status].(map[string]any)
value := response["content"].(map[string]any)["application/json"].(map[string]any)["examples"].(map[string]any)[tc.name].(map[string]any)["value"].(map[string]any)
if value["code"] != tc.code {
t.Fatal(value)
}
}
}

View File

@@ -0,0 +1,12 @@
package apigen
import "testing"
func TestErrorInheritanceConsumer(t *testing.T) {
testCatalogConsumer(t, "errorInheritance.api", "errorInheritance", map[string]string{
"internal/logic/account/plain.go": "service.go",
"internal/logic/account/raw.go": "emptyService.go",
"internal/logic/account/cleared.go": "emptyService.go",
"internal/handler/account/boundedManual.go": "bounded.go",
})
}

View File

@@ -0,0 +1,86 @@
package apigen
import (
"path/filepath"
"strings"
"testing"
)
func TestErrorParamsConsumer(t *testing.T) { testCatalogConsumer(t, "errorParams.api", "errorParams") }
func TestErrorParamsDocumentAndValidation(t *testing.T) {
path := filepath.Join(frameworkRoot(t), "examples/contracts/errorParams.api")
source := readFile(t, path)
spec, err := Parse(path)
if err != nil {
t.Fatal(err)
}
compiled, err := Compile(spec)
if err != nil {
t.Fatal(err)
}
doc := document(t, compiled)
definitions := doc["x-omni-errors"].([]any)
found := false
for _, value := range definitions {
e := value.(map[string]any)
if e["name"] == "TextTooLong" {
found = true
params := e["params"].([]any)
if len(params) != 2 || params[0].(map[string]any)["name"] != "label" || params[1].(map[string]any)["type"] != "int" || e["message"] != "{label}不能超过{max}个字符" {
t.Fatal(e)
}
}
}
if !found {
t.Fatal("missing template definition")
}
for _, path := range []string{"/token", "/raw"} {
method := "post"
if path == "/raw" {
method = "get"
}
response := operation(t, doc, path, method)["responses"].(map[string]any)["200"].(map[string]any)
media := response["content"].(map[string]any)["application/json"].(map[string]any)
value := media["examples"].(map[string]any)["TextTooLong"].(map[string]any)["value"].(map[string]any)
if value["msg"] != "企业名称不能超过100个字符" || value["code"] != float64(400002) {
t.Fatal(value)
}
if _, ok := value["data"]; !ok {
t.Fatal("data null omitted", value)
}
if len(media["schema"].(map[string]any)["anyOf"].([]any)) != 2 {
t.Fatal("success schema lost")
}
}
for _, change := range [][2]string{
{`"{label}不能超过{max}个字符"`, `"{label}不能超过{missing}个字符"`},
{`"type": "int"`, `"type": "unknown"`},
{`"example": 100`, `"example": "100"`},
{`"name": "max"`, `"name": "label"`},
{`"name": "max"`, `"name": "type"`},
{`successCode: 0`, `successCode: 0\n messages: {"TEXT_TOO_LONG":"{label}不能超过{max}个字符"}`},
} {
changed := strings.Replace(source, change[0], strings.ReplaceAll(change[1], `\n`, "\n"), 1)
s, err := parseText(t, changed)
if err == nil {
_, err = Compile(s)
}
if err == nil {
t.Fatal("accepted", change)
}
}
// Removing examples still produces a concrete, deterministic example.
noExamples := strings.ReplaceAll(strings.ReplaceAll(source, `, "example": "企业名称"`, ""), `, "example": 100`, "")
s, err := parseText(t, noExamples)
if err != nil {
t.Fatal(err)
}
c, err := Compile(s)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(c.Document), "label不能超过1个字符") {
t.Fatal("missing generated example")
}
}

View File

@@ -0,0 +1,207 @@
package apigen
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"text/scanner"
"unicode/utf8"
)
// Format normalizes indentation and blank lines without rewriting tokens,
// comments, quoted metadata, struct tags or multiline literal contents.
func Format(source []byte) ([]byte, error) {
if len(source) > 1<<20 {
return nil, errors.New("api: source size limit exceeded")
}
var scan scanner.Scanner
scan.Init(bytes.NewReader(source))
scan.Mode = scanner.ScanIdents | scanner.ScanInts | scanner.ScanStrings | scanner.ScanRawStrings | scanner.ScanComments
var scanErr error
scan.Error = func(s *scanner.Scanner, msg string) {
if scanErr == nil {
scanErr = fmt.Errorf("%s: %s", s.Position, msg)
}
}
depths := map[int]int{}
protected := map[int]bool{}
depth := 0
for k := scan.Scan(); k != scanner.EOF; k = scan.Scan() {
line := scan.Position.Line
value := scan.TokenText()
if k == '}' || k == ')' {
depth--
if depth < 0 {
return nil, errors.New("api: unmatched closing delimiter")
}
}
if _, ok := depths[line]; !ok {
depths[line] = depth
}
// Preserve interior lines exactly, including the closing line of a literal.
count := strings.Count(value, "\n")
for n := 1; n <= count; n++ {
protected[line+n] = true
}
if k == '{' || k == '(' {
depth++
}
}
if scanErr != nil {
return nil, scanErr
}
if depth != 0 {
return nil, errors.New("api: unclosed delimiter")
}
lines := strings.Split(string(source), "\n")
out := make([]string, 0, len(lines))
outProtected := map[int]bool{}
blank := true
lastDepth := 0
for i, line := range lines {
if protected[i+1] {
out = append(out, line)
outProtected[len(out)] = true
blank = false
continue
}
trimmed := strings.TrimSpace(line)
if trimmed == "" {
if !blank {
out = append(out, "")
blank = true
}
continue
}
d, ok := depths[i+1]
if ok {
lastDepth = d
}
out = append(out, strings.Repeat(" ", lastDepth)+trimmed)
blank = false
}
for len(out) > 0 && out[len(out)-1] == "" {
out = out[:len(out)-1]
}
alignFieldLines(out, outProtected)
return []byte(strings.Join(out, "\n") + "\n"), nil
}
// Only complete, single-line fields are aligned. Literal bytes and multiline
// comment contents are never candidates, even when they resemble a field.
var singleLineField = regexp.MustCompile("^([ \\t]*)([A-Z][A-Za-z0-9_]*)[ \\t]+([^ \\t`]+)[ \\t]+(`[^`]*`)[ \\t]*(//.*)?$")
func alignFieldLines(lines []string, protected map[int]bool) {
for start := 0; start < len(lines); {
var fields [][]string
widths := [3]int{}
end := start
for end < len(lines) && !protected[end+1] {
parts := singleLineField.FindStringSubmatch(lines[end])
if parts == nil || (len(fields) > 0 && parts[1] != fields[0][1]) {
break
}
for col := range widths {
if n := utf8.RuneCountInString(parts[col+2]); n > widths[col] {
widths[col] = n
}
}
fields = append(fields, parts)
end++
}
for i, parts := range fields {
line := parts[1]
for col := range widths {
line += parts[col+2]
if col < 2 || parts[5] != "" {
line += strings.Repeat(" ", widths[col]-utf8.RuneCountInString(parts[col+2])+1)
}
}
lines[start+i] = line + parts[5]
}
if end == start {
end++
}
start = end
}
}
// FormatProject validates the full contract before writing any local source.
// Check mode is read-only and reports every file needing formatting.
func FormatProject(entry string, check bool) ([]string, error) {
spec, err := Parse(entry)
if err != nil {
return nil, err
}
if _, err = Compile(spec); err != nil {
return nil, err
}
real, err := filepath.EvalSymlinks(entry)
if err != nil {
return nil, err
}
root := filepath.Dir(real)
names := make([]string, 0, len(spec.Sources))
for name := range spec.Sources {
if strings.HasPrefix(name, "@") || filepath.Ext(name) != ".api" {
continue
}
names = append(names, name)
}
sort.Strings(names)
next := map[string][]byte{}
var changed []string
for _, name := range names {
data, err := Format(spec.Sources[name])
if err != nil {
return nil, fmt.Errorf("%s: %w", name, err)
}
if !bytes.Equal(data, spec.Sources[name]) {
next[name] = data
changed = append(changed, name)
}
}
if check {
if len(changed) > 0 {
return changed, fmt.Errorf("API format drift: %s", strings.Join(changed, ", "))
}
return changed, nil
}
for _, name := range changed {
path := filepath.Join(root, filepath.FromSlash(name))
old, err := os.ReadFile(path)
if err != nil {
return changed, err
}
if !bytes.Equal(old, spec.Sources[name]) {
return changed, fmt.Errorf("API source changed during formatting: %s", name)
}
stat, err := os.Stat(path)
if err != nil {
return changed, err
}
f, err := os.CreateTemp(filepath.Dir(path), ".omni-format-*")
if err != nil {
return changed, err
}
tmp := f.Name()
err = f.Chmod(stat.Mode().Perm())
if err == nil {
_, err = f.Write(next[name])
}
err = errors.Join(err, f.Close())
if err == nil {
err = os.Rename(tmp, path)
}
_ = os.Remove(tmp)
if err != nil {
return changed, err
}
}
return changed, nil
}

View File

@@ -0,0 +1,730 @@
package apigen
import (
"encoding/json"
"errors"
"fmt"
"go/format"
"os"
"path/filepath"
"runtime/debug"
"sort"
"strconv"
"strings"
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/internal/genfs"
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold"
"golang.org/x/mod/modfile"
"golang.org/x/mod/module"
"golang.org/x/mod/semver"
)
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
// Runtime-only releases need not raise the managed template compatibility floor.
const minimumVersion = "v0.22.0-preview"
const currentVersion = "v0.28.1-preview"
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
type Options struct {
generation GeneratorConfig
lockHeld bool
documentCatalogs map[string][]byte
Database, Storage string
API, Dir, Module, FrameworkDir, FrameworkVersion, LogOutput, LogFormat string
Check bool
DryRun bool
}
type FileChange struct {
Path string
Action string
}
type Output struct {
Generated, Created, Preserved, Removed []string
Changes []FileChange
Diagnostics []Diagnostic
}
// Generate creates or safely updates one contract-driven project. It renders
// and validates every output before applying an ownership-checked transaction.
func Generate(o Options) (Output, error) {
if o.Check && o.DryRun {
return Output{}, errors.New("--check and --dry-run cannot be combined")
}
if o.API == "" || o.Dir == "" {
return Output{}, errors.New("--api and --dir are required")
}
spec, err := parseWithCatalogs(o.API, o.documentCatalogs)
if err != nil {
return Output{}, err
}
compiled, err := Compile(spec)
if err != nil {
return Output{}, err
}
return generate(o, compiled, false)
}
const starterAPI = "syntax = \"v1\"\n\ninfo (\n title: \"Greeting API\"\n version: \"1.0.0\"\n)\n\ntype GreetReq {\n Name string `path:\"name\" binding:\"required,max=80\"`\n}\ntype GreetResp {\n Message string `json:\"message\"`\n}\n\n@server (\n group: greet\n prefix: /api\n)\nservice greet-api {\n @doc \"Greet a visitor\"\n @handler greet\n get /greet/:name (GreetReq) returns (GreetResp)\n}\n"
// New creates a runnable greeting project, including its editable API file.
func New(o Options) (Output, error) {
if o.API != "" || o.Check || o.DryRun {
return Output{}, errors.New("api new does not accept --api, --check or --dry-run")
}
if o.Dir == "" {
return Output{}, errors.New("project directory required")
}
if entries, err := os.ReadDir(o.Dir); err == nil && len(entries) > 0 {
return Output{}, errors.New("api new requires an empty directory")
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
return Output{}, err
}
tokens, err := lex("service.api", starterAPI)
if err != nil {
return Output{}, err
}
spec := &Spec{Version: "1.0.0", Entry: "service.api", Sources: map[string][]byte{"service.api": []byte(starterAPI)}}
p := parser{tokens: tokens, spec: spec}
if err = p.parse(); err != nil {
return Output{}, err
}
c, err := Compile(spec)
if err != nil {
return Output{}, err
}
return generate(o, c, true)
}
func generate(o Options, c *Compiled, starter bool) (Output, error) {
directory, err := filepath.Abs(o.Dir)
if err != nil {
return Output{}, err
}
o.Dir = directory
genConfig, hasGenConfig, err := readGeneratorConfig(directory)
if err != nil {
return Output{}, err
}
o.generation = genConfig
if err = checkRuntimeExtension(directory); err != nil {
return Output{}, err
}
if err = checkConfigLoadExtension(directory); err != nil {
return Output{}, err
}
for _, r := range c.Spec.Routes {
if !r.Manual {
path := filepath.Join(directory, "internal/handler", r.Group, lower(r.Handler)+"Manual.go")
if _, err := os.Lstat(path); err == nil {
return Output{}, fmt.Errorf("manual handler %s exists; migrate and remove it explicitly before switching to automatic generation", path)
} else if !errors.Is(err, os.ErrNotExist) {
return Output{}, err
}
}
}
if err := resolveProfile(&o); err != nil {
return Output{}, err
}
seeds := map[string][]byte{}
requiredVersion := ""
moduleData, readErr := os.ReadFile(filepath.Join(directory, "go.mod"))
if readErr == nil {
mf, err := modfile.Parse("go.mod", moduleData, nil)
if err != nil || mf.Module == nil {
return Output{}, errors.New("invalid project go.mod")
}
if o.Module != "" && o.Module != mf.Module.Mod.Path {
return Output{}, errors.New("--module differs from existing go.mod")
}
o.Module = mf.Module.Mod.Path
found := false
for _, r := range mf.Require {
if r.Mod.Path == frameworkModule {
found = true
requiredVersion = r.Mod.Version
if o.FrameworkVersion != "" && o.FrameworkVersion != r.Mod.Version {
return Output{}, errors.New("existing framework version differs; update go.mod explicitly")
}
}
}
if !found {
return Output{}, errors.New("existing go.mod must require go-omni")
}
if semver.Compare(requiredVersion, minimumVersion) < 0 {
localCurrent := false
for _, r := range mf.Replace {
if r.Old.Path == frameworkModule && r.New.Version == "" {
path := r.New.Path
if !filepath.IsAbs(path) {
path = filepath.Join(directory, path)
}
if source, err := os.ReadFile(filepath.Join(path, "config", "references.go")); err == nil && strings.Contains(string(source), "func WithLookupEnv(") {
localCurrent = true
}
}
}
if !localCurrent {
return Output{}, fmt.Errorf("existing framework dependency predates the generated configuration helpers; use %s or a current local replacement", minimumVersion)
}
}
if o.FrameworkDir != "" {
matched := false
for _, r := range mf.Replace {
if r.Old.Path == frameworkModule && r.New.Version == "" {
replacement := r.New.Path
if !filepath.IsAbs(replacement) {
replacement = filepath.Join(directory, replacement)
}
matched = canonical(replacement) == canonical(o.FrameworkDir)
}
}
if !matched {
return Output{}, errors.New("existing framework replacement differs; update go.mod explicitly")
}
}
} else if !errors.Is(readErr, os.ErrNotExist) {
return Output{}, readErr
} else {
if o.Module == "" {
o.Module = filepath.Base(directory)
}
data, err := moduleFile(o)
if err != nil {
return Output{}, err
}
seeds["go.mod"] = data
}
capabilityModule := moduleData
if seeded, ok := seeds["go.mod"]; ok {
capabilityModule = seeded
}
if err := checkI18nRuntime(c.Spec, capabilityModule, directory); err != nil {
return Output{}, err
}
if err = module.CheckImportPath(o.Module); err != nil {
return Output{}, err
}
generated, base, err := renderProject(o, c, starter)
if err != nil {
return Output{}, err
}
for name, data := range base {
seeds[name] = data
}
if !hasGenConfig {
raw, _ := json.MarshalIndent(genConfig, "", " ")
seeds[generatorConfigFile] = append(raw, '\n')
}
// The API source remains developer-owned. A relocated project regenerates
// from its own api/ tree, including all imports.
entry := "api/" + c.Spec.Entry
for name, data := range c.Spec.Sources {
if !strings.HasPrefix(name, "@") {
seeds["api/"+name] = data
}
}
if len(c.Spec.SharedRoots) > 0 {
raw, e := sharedSeeds(c.Spec, directory)
if e != nil {
return Output{}, e
}
seeds["api/"+importsFile] = raw
}
if o.API != "" {
source, _ := filepath.Abs(o.API)
sourceReal, _ := filepath.EvalSymlinks(source)
targetReal := canonical(directory)
rel, e := filepath.Rel(targetReal, sourceReal)
if e == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
entry = filepath.ToSlash(rel)
delete(seeds, "api/"+importsFile)
for name := range c.Spec.Sources {
delete(seeds, "api/"+name)
}
}
}
// Source collisions must never leave a copied contract different from the
// generated code. On updates, use the developer-owned API in the project.
for name, data := range seeds {
if strings.HasSuffix(name, ".api") || filepath.Base(name) == catalogDocumentFile || filepath.Base(name) == importsFile {
current, e := os.ReadFile(filepath.Join(directory, filepath.FromSlash(name)))
if e == nil && string(current) != string(data) {
return Output{}, fmt.Errorf("%s already differs; regenerate from that project API instead", name)
}
}
}
seeds["README.md"] = []byte(projectREADME(o.Module, entry) + infrastructureREADME(o))
for _, files := range []map[string][]byte{generated, seeds} {
for name, data := range files {
if strings.HasSuffix(name, ".go") {
data, err = format.Source(data)
if err != nil {
return Output{}, fmt.Errorf("format %s: %w", name, err)
}
files[name] = data
}
}
}
currentConfig, stillExists, err := readGeneratorConfig(directory)
if err != nil {
return Output{}, err
}
beforeConfig, _ := json.Marshal(genConfig)
afterConfig, _ := json.Marshal(currentConfig)
if hasGenConfig != stillExists || genConfig.inputDigest != currentConfig.inputDigest || string(beforeConfig) != string(afterConfig) {
return Output{}, errors.New("generation configuration changed during generation; retry")
}
out, err := apply(directory, o.Module, entry, generated, seeds, o.Check, o.DryRun, o.profile(), snapshotContract(c.Spec), o.lockHeld)
expectedVersion := currentVersion
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Path == frameworkModule && info.Main.Sum != "" && semver.IsValid(info.Main.Version) {
expectedVersion = info.Main.Version
}
if requiredVersion != "" && requiredVersion != expectedVersion {
out.Diagnostics = append(out.Diagnostics, Diagnostic{Severity: "review", Code: "frameworkVersion", Path: "go.mod", Before: requiredVersion, After: expectedVersion, Message: "project dependency differs from this CLI version; use matching CLI/framework versions, or verify the explicit local replacement"})
sortDiagnostics(out.Diagnostics)
}
return out, err
}
func moduleFile(o Options) ([]byte, error) {
if err := module.CheckImportPath(o.Module); err != nil {
return nil, err
}
root := o.FrameworkDir
version := o.FrameworkVersion
if root == "" && version == "" {
wd, _ := os.Getwd()
for wd != "" {
data, err := os.ReadFile(filepath.Join(wd, "go.mod"))
if err == nil {
mf, e := modfile.Parse("go.mod", data, nil)
if e == nil && mf.Module != nil && mf.Module.Mod.Path == frameworkModule {
root = wd
}
break
}
parent := filepath.Dir(wd)
if parent == wd {
break
}
wd = parent
}
}
if version == "" {
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Path == frameworkModule && info.Main.Sum != "" {
version = info.Main.Version
} else {
version = currentVersion
}
}
if !semver.IsValid(version) || semver.Major(version) != "v0" && semver.Major(version) != "v1" {
return nil, errors.New("invalid framework version")
}
if root == "" && semver.Compare(version, minimumVersion) < 0 {
return nil, fmt.Errorf("API generation needs %s or newer; use --framework-dir for unpublished source", minimumVersion)
}
text := "module " + o.Module + "\n\ngo 1.22.0\n\nrequire " + frameworkModule + " " + version + "\n"
if root != "" {
root = canonical(root)
data, err := os.ReadFile(filepath.Join(root, "go.mod"))
if err != nil {
return nil, err
}
mf, err := modfile.Parse("go.mod", data, nil)
if err != nil || mf.Module == nil || mf.Module.Mod.Path != frameworkModule {
return nil, errors.New("--framework-dir must contain go-omni")
}
if _, err = os.Stat(filepath.Join(root, "contract", "contract.go")); err != nil {
return nil, errors.New("local framework lacks API contract support")
}
relative, err := filepath.Rel(canonical(o.Dir), root)
if err != nil {
return nil, err
}
text += "\nreplace " + frameworkModule + " => " + strconv.Quote(filepath.ToSlash(relative)) + "\n"
}
return []byte(text), nil
}
func canonical(path string) string { return genfs.Canonical(path) }
func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map[string][]byte, error) {
generated, seeds := map[string][]byte{}, map[string][]byte{}
put := func(files map[string][]byte, name, content string) { files[name] = []byte(content) }
if len(c.Spec.Errors) > 0 {
content := "package apierrors\nimport(\"encoding/json\"; omnierrors " + strconv.Quote(frameworkModule+"/errors") + ")\n"
content += fmt.Sprintf("func Catalog()omnierrors.Catalog{var c omnierrors.Catalog;if err:=json.Unmarshal([]byte(%q),&c);err!=nil{panic(err)};return c}\nvar errorRegistry=omnierrors.MustRegistry(Catalog())\n", errorCatalogJSON(c.Spec, nil))
for _, e := range c.Spec.Errors {
var args, values []string
for _, p := range e.Params {
name := p.Name
switch name {
case "string", "any", "omnierrors", "errorRegistry", "int", "int32", "int64", "float64", "bool":
name = "_" + name
}
args = append(args, name+" "+p.Type)
values = append(values, strconv.Quote(p.Name)+":"+name)
}
ref := fmt.Sprintf("omnierrors.ByCode(Code%s)", e.Name)
if len(args) > 0 {
ref += ".WithParams(map[string]any{" + strings.Join(values, ",") + "})"
}
content += fmt.Sprintf("const Code%s=%q\n// %s returns a fresh, resolved error declared in the API contract.\nfunc %s(%s)*omnierrors.APIError{return errorRegistry.Resolve(%s)}\n", e.Name, e.Code, e.Name, e.Name, strings.Join(args, ","), ref)
}
put(generated, "internal/apierrors/errorsGen.go", generatedHeader+content)
}
types := "package types\n\n"
needsUpload := false
for _, b := range c.Bindings {
needsUpload = needsUpload || b.HasMultipart()
}
if needsUpload {
types += fmt.Sprintf("import %q\n", frameworkModule+"/contract")
}
if len(c.Spec.Scalars) > 0 {
types += "import("
for i, d := range c.Spec.Scalars {
types += fmt.Sprintf("scalar%d %q;", i, d.GoImport)
}
types += ")\n"
registry := fmt.Sprintf("package types\nimport %q\nfunc ContractScalars()[]contract.Scalar{return []contract.Scalar{", frameworkModule+"/contract")
for i, d := range c.Spec.Scalars {
types += fmt.Sprintf("type %s = scalar%d.%s\n", d.Name, i, d.GoType)
registry += fmt.Sprintf("contract.ScalarOf[%s](%q,%q,%t),", d.Name, d.JSONInput, d.JSONOutput, d.Text)
}
registry += "}}\n"
put(generated, "internal/types/scalarsGen.go", generatedHeader+registry)
}
if needsUpload {
types += "type File = contract.File\n"
}
bySource := map[string]string{}
for _, t := range c.Spec.Types {
name := dtoFile(t.Position)
if bySource[name] == "" {
bySource[name] = "package types\n\n"
}
text := "type " + t.Name + " struct {\n"
for _, f := range t.Fields {
if f.Description != "" {
for _, line := range strings.Split(f.Description, "\n") {
text += "// " + line + "\n"
}
}
text += fmt.Sprintf("%s %s `%s`\n", f.Name, f.Type, fieldTag(f))
}
bySource[name] += text + "}\n"
}
for name, text := range bySource {
put(generated, "internal/types/"+name, generatedHeader+text)
}
put(generated, "internal/types/typesGen.go", generatedHeader+types)
put(generated, "docs/openapi.json", string(c.Document)+"\n")
put(generated, "internal/handler/openapiGen.go", generatedHeader+fmt.Sprintf("package handler\nimport (\"net/http\"; %q)\nconst OpenAPIDocument = %q\nfunc OpenAPI(c *httpx.Context) error { return c.WriteResponse(200,http.Header{\"Content-Type\": {\"application/json; charset=utf-8\"}},[]byte(OpenAPIDocument)) }\n", frameworkModule+"/httpx", string(c.Document)+"\n"))
policySource := fmt.Sprintf("package handler\nimport(\"encoding/json\";\"strings\";%q; omnierrors %q)\nvar responseTemplates=map[string]response.Encoder{\"\":response.Default(),\n", frameworkModule+"/response", frameworkModule+"/errors")
for _, definition := range c.Spec.Responses {
data, err := json.Marshal(definition.Config)
if err != nil {
return nil, nil, err
}
policySource += fmt.Sprintf("%q: newResponseTemplate(%q),\n", definition.Name, string(data))
}
policySource += "}\nfunc newResponseTemplate(raw string)*response.Formatter{var cfg response.Config;decoder:=json.NewDecoder(strings.NewReader(raw));decoder.UseNumber();if err:=decoder.Decode(&cfg);err!=nil{panic(err)};return response.MustNew(cfg)}\n"
policySource += "func responseEncoder(name string,raw bool,catalogJSON string)response.Encoder{encoder:=responseTemplates[name];if encoder==nil{panic(\"unknown response template\")};var catalog omnierrors.Catalog;if err:=json.Unmarshal([]byte(catalogJSON),&catalog);err!=nil{panic(err)};if len(catalog.Definitions)>0{encoder=response.MustWithErrors(catalog,encoder)};if raw{encoder=response.RawJSON(encoder)};return encoder}\n"
put(generated, "internal/handler/responseGen.go", generatedHeader+policySource)
imports := fmt.Sprintf("import (\"fmt\"; %q; %q; %q;", frameworkModule+"/httpx", frameworkModule+"/openapi", o.Module+"/internal/svc")
routes := "func RegisterRoutes(router openapi.Registrar, deps *svc.ServiceContext) error {\n if router==nil || deps==nil {return fmt.Errorf(\"router and dependencies required\")}\n"
if len(c.Spec.Scalars) > 0 {
imports += fmt.Sprintf("%q;%q;", frameworkModule+"/contract", o.Module+"/internal/types")
routes += "if err:=contract.ValidateScalars(types.ContractScalars()...);err!=nil{return err}\n"
}
middlewareSet := map[string]bool{}
for _, r := range c.Spec.Routes {
for _, m := range r.Middleware {
middlewareSet[m] = true
}
}
var middlewareNames []string
for m := range middlewareSet {
middlewareNames = append(middlewareNames, m)
}
sort.Strings(middlewareNames)
for _, m := range middlewareNames {
routes += fmt.Sprintf("if deps.Middlewares[%q]==nil {return fmt.Errorf(%q)}\n", m, "missing middleware "+m)
}
for i, group := range c.Groups {
imports += fmt.Sprintf("h%d %q; s%d %q;", i, o.Module+"/internal/handler/"+group, i, o.Module+"/internal/logic/"+group)
routes += fmt.Sprintf("h%d := h%d.New(s%d.New(deps),deps)\n", i, i, i)
groupRoutes := []Route{}
for _, r := range c.Spec.Routes {
if r.Group == group {
groupRoutes = append(groupRoutes, r)
}
}
packageName := filepath.Base(group)
text := "package " + packageName + "\nimport(\"context\"; \"fmt\";"
needsTypes, needsBinder, hasAutomatic := false, false, false
for _, r := range groupRoutes {
if r.Manual {
continue
}
hasAutomatic = true
needsTypes = needsTypes || r.Request != "" || responseNeedsTypes(r.Response)
needsBinder = needsBinder || !r.CustomBinding
}
text += fmt.Sprintf("%q;%q;", frameworkModule+"/httpx", o.Module+"/internal/svc")
if needsTypes {
text += fmt.Sprintf("%q;", o.Module+"/internal/types")
}
if needsBinder {
text += fmt.Sprintf("%q;", frameworkModule+"/contract")
}
text += ")\n"
text += "type Logic interface {\n"
for _, r := range groupRoutes {
if !r.Manual {
text += signature(r) + "\n"
}
}
text += "}\ntype Handler struct {logic Logic;deps *svc.ServiceContext}\nfunc New(s Logic,deps ...*svc.ServiceContext)*Handler {h:=&Handler{logic:s};if len(deps)>0{h.deps=deps[0]};return h}\n"
for _, r := range groupRoutes {
name := upper(r.Handler)
// The response policy and body reader cap are installed before App middleware.
if r.MaxBodyBytes > 0 {
routes += fmt.Sprintf("httpx.HandleWithBodyLimit(router,%q,%q,%d,responseEncoder(%q,%t,%q),h%d.%s", strings.ToUpper(r.Method), r.Path, r.MaxBodyBytes, responseName(r), r.ResponseFormat == "raw", errorCatalogJSON(c.Spec, append([]string{}, r.Errors...)), i, name)
} else {
routes += fmt.Sprintf("httpx.HandleWithResponse(router,%q,%q,responseEncoder(%q,%t,%q),h%d.%s", strings.ToUpper(r.Method), r.Path, responseName(r), r.ResponseFormat == "raw", errorCatalogJSON(c.Spec, append([]string{}, r.Errors...)), i, name)
}
for _, m := range r.Middleware {
routes += fmt.Sprintf(",deps.Middlewares[%q]", m)
}
routes += ")\n"
if r.Manual {
put(seeds, "internal/handler/"+group+"/"+lower(r.Handler)+"Manual.go", fmt.Sprintf("package %s\nimport(%q;apierrors %q)\n// %s owns raw/multipart binding and response output. h.deps holds project dependencies.\n// This file is preserved on regeneration.\nfunc(h *Handler)%s(c *httpx.Context)error{return apierrors.New(501,\"NOT_IMPLEMENTED\",\"manual handler is not implemented\")}\n", packageName, frameworkModule+"/httpx", frameworkModule+"/errors", name, name))
continue
}
if !r.CustomBinding {
scalarArgs := ""
if len(c.Spec.Scalars) > 0 {
scalarArgs = "types.ContractScalars()..."
}
requestType := "struct{}"
if r.Request != "" {
requestType = "types." + r.Request
} else {
scalarArgs = ""
}
text += fmt.Sprintf("var %sRequest = contract.MustNew[%s](%s)\n", lower(r.Handler), requestType, scalarArgs)
}
text += fmt.Sprintf("func(h *Handler) %s(c *httpx.Context)error {\n", name)
call := "c.Context()"
if r.Request != "" {
if r.CustomBinding {
text += fmt.Sprintf("var req types.%s\nif err:=h.bind%s(c,&req);err!=nil{return err}\n", r.Request, name)
put(seeds, "internal/handler/"+group+"/"+lower(r.Handler)+"Bind.go", fmt.Sprintf("package %s\nimport(%q;%q;apierrors %q)\n// bind%s is project-owned; repeated generation preserves it.\nfunc(h *Handler)bind%s(c *httpx.Context,req *types.%s)error{return apierrors.New(501,\"BINDING_NOT_IMPLEMENTED\",\"custom binding is not implemented\")}\n", packageName, frameworkModule+"/httpx", o.Module+"/internal/types", frameworkModule+"/errors", name, name, r.Request))
} else if r.BoundMultipart {
text += fmt.Sprintf("var req types.%s\ncleanup,err:=%sRequest.BindMultipart(c.Request(),httpx.RouteParams(c.Context()),&req,contract.MultipartOptions{MaxBodyBytes:%d,MaxParts:%d,MaxFieldBytes:%d,MaxFileBytes:%d,Policy:%s});if err!=nil{return err};defer cleanup()\n", r.Request, lower(r.Handler), r.MaxBodyBytes, r.MaxParts, r.MaxFieldBytes, r.MaxFileBytes, policyLiteral(r.RequestPolicy))
} else {
limit := ""
if r.MaxBodyBytes > 0 {
limit = fmt.Sprintf(",%d", r.MaxBodyBytes)
}
policy := r.RequestPolicy
if r.BodyMode == "raw" || r.BodyMode == "multipart" {
policy.Body = "auto"
}
text += fmt.Sprintf("var req types.%s\nif err:=%sRequest.BindWithPolicy(c.Request(),httpx.RouteParams(c.Context()),&req,%s%s);err!=nil{return err}\n", r.Request, lower(r.Handler), policyLiteral(policy), limit)
}
call += ", &req"
} else {
limit := ""
if r.MaxBodyBytes > 0 {
limit = fmt.Sprintf(",%d", r.MaxBodyBytes)
}
policy := r.RequestPolicy
if r.BodyMode == "raw" || r.BodyMode == "multipart" {
policy.Body = "auto"
}
text += fmt.Sprintf("if err:=%sRequest.BindWithPolicy(c.Request(),httpx.RouteParams(c.Context()),&struct{}{},%s%s);err!=nil{return err}\n", lower(r.Handler), policyLiteral(policy), limit)
}
streamingBody := streamsBody(r)
if streamingBody {
text += fmt.Sprintf("body,err:=httpx.NewBody(c.Request(),%q);if err!=nil{return err}\n", r.BodyMode)
call += ",body"
}
if r.Produces != "" {
text += fmt.Sprintf("resp,err:=h.logic.%s(%s);if err!=nil{if resp!=nil{_ = resp.Close()};return err}\n", name, call)
if streamingBody {
text += "if err:=body.Complete();err!=nil{if resp!=nil{_ =resp.Close()};return err}\n"
}
text += fmt.Sprintf("return c.WriteStream(%d,%q,resp)\n}\n", r.Status, r.Produces)
} else if responseGoType(r.Response) != "" {
text += fmt.Sprintf("resp,err:=h.logic.%s(%s);if err!=nil{return err};\n", name, call)
kind, _ := responseShape(r.Response)
if kind == "object" {
text += fmt.Sprintf("if resp==nil{return fmt.Errorf(%q)}\n", name+" returned a nil response")
}
if kind == "array" {
text += "if resp==nil{resp=make(" + responseGoType(r.Response) + ",0)}\n"
}
if streamingBody {
text += "if err:=body.Complete();err!=nil{return err}\n"
}
method := map[int]string{200: "OK", 201: "Created", 202: "Accepted"}[r.Status]
text += "return c." + method + "(resp)\n}\n"
} else {
text += fmt.Sprintf("if err:=h.logic.%s(%s);err!=nil{return err};", name, call)
if streamingBody {
text += "if err:=body.Complete();err!=nil{return err};"
}
if r.Response == "null" {
method := map[int]string{200: "OK", 201: "Created", 202: "Accepted"}[r.Status]
text += "return c." + method + "(nil)\n}\n"
} else {
text += "return c.NoContent()\n}\n"
}
}
service := fmt.Sprintf("package %s\nimport(\"context\"; apierrors %q;", packageName, frameworkModule+"/errors")
if r.Request != "" || responseNeedsTypes(r.Response) {
service += fmt.Sprintf("%q;", o.Module+"/internal/types")
}
if streamingBody || r.Produces != "" {
service += fmt.Sprintf("%q;", frameworkModule+"/httpx")
}
service += ")\n"
service += "// " + name + " implements the business operation. This file is never overwritten.\nfunc(s *Logic) " + signature(r) + " {\n"
if starter && r.Handler == "greet" {
service = strings.Replace(service, "apierrors "+strconv.Quote(frameworkModule+"/errors")+";", "\"fmt\";", 1)
service += "s.deps.Logger.InfoContext(ctx,\"greet.called\")\nreturn &types.GreetResp{Message:fmt.Sprintf(\"Hello %s\",req.Name)},nil\n}"
} else {
service += "// TODO: implement business behavior and use s.deps for dependencies.\n"
if responseGoType(r.Response) != "" || r.Produces != "" {
service += "return nil, "
} else {
service += "return "
}
service += "apierrors.New(501,\"NOT_IMPLEMENTED\",\"operation is not implemented\")\n}"
}
put(seeds, "internal/logic/"+group+"/"+lower(r.Handler)+".go", service)
}
if !hasAutomatic {
text = strings.Replace(text, strconv.Quote(frameworkModule+"/httpx")+";", "", 1)
text = strings.Replace(text, "\"context\";", "", 1)
}
if !hasResponse(groupRoutes) {
text = strings.Replace(text, "\"fmt\";", "", 1)
}
put(generated, "internal/handler/"+group+"/handlersGen.go", generatedHeader+text)
put(seeds, "internal/logic/"+group+"/logic.go", fmt.Sprintf("package %s\nimport %q\ntype Logic struct {deps *svc.ServiceContext}\nfunc New(deps *svc.ServiceContext)*Logic{return &Logic{deps:deps}}\n", packageName, o.Module+"/internal/svc"))
if o.generation.Repository == "group" {
put(seeds, "internal/repository/"+group+"/repository.go", "// Package "+packageName+" owns persistence contracts for this business group.\n// Define repository interfaces and implementations here; HTTP DTOs do not\n// determine a database schema. This file is never overwritten.\npackage "+packageName+"\n")
}
}
put(generated, "internal/handler/routesGen.go", generatedHeader+"package handler\n"+imports+")\n"+routes+"return nil\n}\n")
registry := "package middleware\nimport " + strconv.Quote(frameworkModule+"/httpx") + "\nfunc Defaults()map[string]httpx.Middleware{return map[string]httpx.Middleware{\n"
for _, m := range middlewareNames {
registry += fmt.Sprintf("%q:%s,\n", m, upper(m))
put(seeds, "internal/middleware/"+lower(m)+".go", fmt.Sprintf("package middleware\nimport(%q; apierrors %q)\n// %s is a developer-owned hook. Implement it before enabling this route.\nfunc %s(next httpx.Handler)httpx.Handler{return func(c *httpx.Context)error {return apierrors.New(501,\"MIDDLEWARE_NOT_IMPLEMENTED\",\"middleware is not implemented\")}}\n", frameworkModule+"/httpx", frameworkModule+"/errors", upper(m), upper(m)))
}
put(generated, "internal/middleware/registryGen.go", generatedHeader+registry+"}}\n")
if o.generation.Middleware == "factories" {
renderMiddlewareFactories(middlewareNames, generated, seeds)
}
for name, source := range baseTemplates {
content := strings.ReplaceAll(source, "MODULE", o.Module)
content = strings.ReplaceAll(content, "LOGFORMAT", o.LogFormat)
content = strings.ReplaceAll(content, "LOGOUTPUT", o.LogOutput)
if name == "etc/config.yaml" && o.LogOutput == "file" {
content = strings.Replace(content, "output: file", "output: stderr\n file: ./var/log/server.log\n rotation:\n enabled: true\n maxSizeMb: 100\n maxBackups: 10\n maxAgeDays: 7\n compress: true", 1)
}
if name == "main.go" {
put(generated, name, generatedHeader+content)
} else {
put(seeds, name, content)
}
}
infra, err := scaffold.APIInfrastructure(o.Module, o.Database, o.Storage, o.LogOutput, o.LogFormat)
if err != nil {
return nil, nil, err
}
for name, data := range infra {
if o.generation.Middleware == "factories" && name == "internal/svc/runtimeGen.go" {
formatted, err := format.Source(data)
if err != nil {
return nil, nil, err
}
source := string(formatted)
source = strings.Replace(source, "Middlewares: middleware.Defaults(),", "", 1)
source = strings.Replace(source, "return c, nil", "if len(c.Middlewares) != 0 { return nil, errors.New(\"factory middleware is registered automatically; remove manual Middlewares assignments from Init\") }\nc.Middlewares, err = middleware.New(c)\nif err != nil { return nil, err }\nreturn c, nil", 1)
data = []byte(source)
}
if strings.HasSuffix(name, "Gen.go") {
generated[name] = append([]byte(generatedHeader), data...)
} else {
seeds[name] = data
}
}
if o.Storage == "local" {
for _, r := range c.Spec.Routes {
if r.Path == "/storage/object" {
return nil, nil, errors.New("/storage/object is reserved by local storage")
}
}
}
put(generated, "internal/handler/appGen.go", generatedHeader+appSource(o, c.Spec))
return generated, seeds, nil
}
func hasResponse(routes []Route) bool {
for _, r := range routes {
if kind, _ := responseShape(r.Response); r.Response != "" && kind == "object" && !r.Manual && r.Produces == "" {
return true
}
}
return false
}
func signature(r Route) string {
s := upper(r.Handler) + "(ctx context.Context"
if r.Request != "" {
s += ",req *types." + r.Request
}
if streamsBody(r) {
s += ",body *httpx.Body"
}
s += ") "
if r.Produces != "" {
return s + "(*httpx.Stream,error)"
}
if result := responseGoType(r.Response); result != "" {
s += "(" + result + ",error)"
} else {
s += "error"
}
return s
}
func streamsBody(r Route) bool {
return r.BodyMode == "raw" || (r.BodyMode == "multipart" && !r.BoundMultipart)
}
func dtoFile(position string) string {
source := position
for i := 0; i < 2; i++ {
if idx := strings.LastIndex(source, ":"); idx >= 0 {
source = source[:idx]
}
}
stem := strings.TrimSuffix(filepath.Base(source), ".api")
var b strings.Builder
for _, r := range stem {
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' {
b.WriteRune(r)
}
}
if b.Len() == 0 {
b.WriteString("contract")
}
return "dto" + upper(b.String()) + digest([]byte(source))[:12] + "Gen.go"
}

View File

@@ -0,0 +1,110 @@
package apigen
import (
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
)
const generatorConfigFile = "omni.gen.json"
// GeneratorConfig contains developer-owned scaffolding options only.
type GeneratorConfig struct {
Repository string `json:"repository,omitempty"`
Middleware string `json:"middleware,omitempty"`
inputDigest string
}
func readGeneratorConfig(root string) (GeneratorConfig, bool, error) {
cfg := GeneratorConfig{Repository: "none", Middleware: "factories"}
if err := safePath(root, generatorConfigFile); err != nil {
return cfg, false, err
}
stat, err := os.Lstat(filepath.Join(root, generatorConfigFile))
if errors.Is(err, os.ErrNotExist) {
// Existing projects keep their handwritten middleware signature until they
// explicitly select factories; do not rewrite or adopt developer files.
if raw, e := os.ReadFile(filepath.Join(root, manifestPath)); e == nil {
var previous manifest
if err := json.Unmarshal(raw, &previous); err != nil {
return cfg, false, err
}
if previous.GeneratorConfig {
return cfg, false, errors.New("omni.gen.json is missing; restore the project-owned generation options before regenerating")
}
cfg.Middleware = "handlers"
} else if !errors.Is(e, os.ErrNotExist) {
return cfg, false, e
}
return cfg, false, nil
}
if err != nil {
return cfg, false, err
}
if !stat.Mode().IsRegular() || stat.Size() > 1<<20 {
return cfg, false, errors.New("omni.gen.json must be a regular file of at most 1 MiB")
}
raw, err := readGeneratorConfigFile(root, generatorConfigFile)
if err != nil {
return cfg, false, err
}
var keys map[string]json.RawMessage
if err = apierrors.DecodeCatalogJSON(raw, &keys); err != nil {
return cfg, false, fmt.Errorf("omni.gen.json: %w", err)
}
for _, key := range []string{"mappings", "mappingFiles"} {
if _, exists := keys[key]; exists {
return cfg, false, errors.New("mapping generation was removed: migrate callers to copyx or ordinary Go, remove mappings/mappingFiles from omni.gen.json, then remove internal/mapping/mappingsGen.go; see COPY.md")
}
}
if err = apierrors.DecodeCatalogJSON(raw, &cfg); err != nil {
return cfg, false, fmt.Errorf("omni.gen.json: %w", err)
}
digest := sha256.Sum256(raw)
cfg.inputDigest = fmt.Sprintf("%x", digest)
if cfg.Repository != "none" && cfg.Repository != "group" {
return cfg, false, errors.New("omni.gen.json: repository must be none or group")
}
if cfg.Middleware != "factories" && cfg.Middleware != "handlers" {
return cfg, false, errors.New("omni.gen.json: middleware must be factories or handlers")
}
return cfg, true, nil
}
// Read through a bounded reader as well as checking stat: a growing file cannot
// make configuration loading unbounded. safePath also rejects symlink escapes.
func readGeneratorConfigFile(root, name string) ([]byte, error) {
if err := safePath(root, name); err != nil {
return nil, err
}
f, err := os.Open(filepath.Join(root, filepath.FromSlash(name)))
if err != nil {
return nil, fmt.Errorf("%s: %w", name, err)
}
defer f.Close()
stat, err := f.Stat()
if err != nil {
return nil, err
}
if !stat.Mode().IsRegular() || stat.Size() > 1<<20 {
return nil, fmt.Errorf("%s must be a regular file of at most 1 MiB", name)
}
raw, err := io.ReadAll(io.LimitReader(f, (1<<20)+1))
if err != nil {
return nil, err
}
if len(raw) > 1<<20 {
return nil, fmt.Errorf("%s exceeds 1 MiB", name)
}
if trimmed := strings.TrimSpace(string(raw)); !strings.HasPrefix(trimmed, "{") {
return nil, fmt.Errorf("%s must contain a JSON object", name)
}
return raw, nil
}

View File

@@ -0,0 +1,96 @@
package apigen
import (
"encoding/json"
"io"
"os"
"path/filepath"
"strings"
"testing"
)
func TestWorkspaceRejectsRemovedMappingWithoutWrites(t *testing.T) {
root := t.TempDir()
for _, name := range []string{"one", "two"} {
if _, err := New(Options{Dir: filepath.Join(root, name), Module: "example.com/" + name, FrameworkDir: frameworkRoot(t)}); err != nil {
t.Fatal(err)
}
}
firstAPI := filepath.Join(root, "one/api/service.api")
writeFile(t, firstAPI, strings.Replace(readFile(t, firstAPI), "Greet a visitor", "Changed description", 1))
writeFile(t, filepath.Join(root, "two", generatorConfigFile), `{"mappingFiles":["../../private.json"]}`)
workspace := filepath.Join(root, "omni.workspace.json")
writeFile(t, workspace, `{"services":[{"dir":"one"},{"dir":"two"}]}`)
before := snapshot(t, root)
for _, operation := range []string{"generate", "check"} {
if err := Workspace(workspace, operation, io.Discard); err == nil || !strings.Contains(err.Error(), "mapping generation was removed") {
t.Fatal(operation, err)
}
if !sameFiles(before, snapshot(t, root)) {
t.Fatal("workspace migration failure wrote files")
}
}
}
func TestRemovedMappingConfiguration(t *testing.T) {
dir := filepath.Join(t.TempDir(), "project")
if _, err := New(Options{Dir: dir, Module: "example.com/removed", FrameworkDir: frameworkRoot(t)}); err != nil {
t.Fatal(err)
}
for _, raw := range []string{`{"mappings":[]}`, `{"mappingFiles":[]}`, `{"mappings":null}`, `{"mappingFiles":["../../private.json"]}`} {
writeFile(t, filepath.Join(dir, generatorConfigFile), raw)
before := snapshot(t, dir)
for _, o := range []Options{{Dir: dir}, {Dir: dir, Check: true}, {Dir: dir, DryRun: true}} {
o.API = filepath.Join(dir, "api/service.api")
if _, err := Generate(o); err == nil || !strings.Contains(err.Error(), "mapping generation was removed") {
t.Fatal(err)
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("rejected config wrote project")
}
}
}
}
func TestOldMappingFileRequiresExplicitMigration(t *testing.T) {
dir := filepath.Join(t.TempDir(), "project")
if _, err := New(Options{Dir: dir, Module: "example.com/removed", FrameworkDir: frameworkRoot(t)}); err != nil {
t.Fatal(err)
}
name := "internal/mapping/mappingsGen.go"
body := generatedHeader + "package mapping\n"
writeFile(t, filepath.Join(dir, name), body)
var m manifest
if err := json.Unmarshal([]byte(readFile(t, filepath.Join(dir, manifestPath))), &m); err != nil {
t.Fatal(err)
}
m.Generated[name] = digest([]byte(body))
raw, _ := json.Marshal(m)
writeFile(t, filepath.Join(dir, manifestPath), string(raw))
before := snapshot(t, dir)
o := Options{Dir: dir, API: filepath.Join(dir, "api/service.api")}
for _, mode := range []string{"generate", "check", "dry-run"} {
o.Check = mode == "check"
o.DryRun = mode == "dry-run"
if _, err := Generate(o); err == nil || !strings.Contains(err.Error(), "mapping generation was removed") {
t.Fatal(err)
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("legacy file deleted or project changed")
}
}
if err := os.Remove(filepath.Join(dir, name)); err != nil {
t.Fatal(err)
}
o.Check = false
o.DryRun = false
if _, err := Generate(o); err != nil {
t.Fatal(err)
}
o.Check = true
if _, err := Generate(o); err != nil {
t.Fatal(err)
}
if strings.Contains(readFile(t, filepath.Join(dir, manifestPath)), name) {
t.Fatal("old ownership retained")
}
}

View File

@@ -0,0 +1,57 @@
package apigen
import (
"fmt"
"os"
"path/filepath"
"strings"
"golang.org/x/mod/modfile"
"golang.org/x/mod/semver"
)
// Translation metadata was introduced in this release. Consumers of older
// runtimes must upgrade or use an explicitly supported local replacement.
const i18nMinimumVersion = "v0.26.0-preview"
func checkI18nRuntime(spec *Spec, moduleData []byte, directory string) error {
needed := false
for _, e := range spec.Errors {
needed = needed || e.Translation != nil
}
if !needed {
return nil
}
mf, err := modfile.Parse("go.mod", moduleData, nil)
if err != nil {
return err
}
version := ""
for _, r := range mf.Require {
if r.Mod.Path == frameworkModule {
version = r.Mod.Version
}
}
for _, r := range mf.Replace {
if r.Old.Path != frameworkModule || r.Old.Version != "" && r.Old.Version != version {
continue
}
if r.New.Version != "" {
version = r.New.Version
continue
}
root := r.New.Path
if !filepath.IsAbs(root) {
root = filepath.Join(directory, root)
}
data, e := os.ReadFile(filepath.Join(root, "errors", "localization.go"))
if e == nil && strings.Contains(string(data), "type Translation struct") {
return nil
}
return fmt.Errorf("translation metadata requires a local framework with errors.Translation support")
}
if semver.Compare(version, i18nMinimumVersion) < 0 {
return fmt.Errorf("translation metadata requires %s or newer; use --framework-dir for unpublished source", i18nMinimumVersion)
}
return nil
}

View File

@@ -0,0 +1,42 @@
package apigen
import (
"path/filepath"
"strings"
"testing"
)
func TestI18nTranslationMetadataAndVersionBoundary(t *testing.T) {
spec, err := Parse(filepath.Join(frameworkRoot(t), "examples/contracts/i18n.api"))
if err != nil {
t.Fatal(err)
}
compiled, err := Compile(spec)
if err != nil {
t.Fatal(err)
}
ref := compiled.Spec.Errors[0].Translation
if ref == nil || ref.Namespace != "limits" || ref.Key != "exceeded" {
t.Fatal(ref)
}
if err := checkI18nRuntime(spec, []byte("module example.com/test\ngo 1.22\nrequire "+frameworkModule+" v0.25.0-preview\n"), t.TempDir()); err == nil {
t.Fatal("old runtime accepted")
}
generated, _, err := renderProject(Options{Module: "example.com/test"}, compiled, false)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(generated["internal/apierrors/errorsGen.go"]), "translation") || !strings.Contains(string(generated["docs/openapi.json"]), "translation") {
t.Fatal("translation metadata lost")
}
bad := readFile(t, filepath.Join(frameworkRoot(t), "examples/contracts/i18n.api"))
bad = strings.Replace(bad, `"namespace":"limits"`, `"namespace":""`, 1)
parsed, err := parseText(t, bad)
if err == nil {
_, err = Compile(parsed)
}
if err == nil {
t.Fatal("invalid translation reference accepted")
}
}
func TestI18nGeneratedConsumer(t *testing.T) { testCatalogConsumer(t, "i18n.api", "i18n") }

View File

@@ -0,0 +1,240 @@
package apigen
import (
"bytes"
"errors"
"fmt"
"go/ast"
"go/build"
"go/format"
goparser "go/parser"
"go/scanner"
gotoken "go/token"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
)
type logicMethod struct {
path, signature string
line int
known bool
}
// checkLogic inspects source only: no project code, init hooks, Go commands or
// dependency downloads are executed. Seeds participate only when files do not
// already exist, matching apply's ownership rules.
func checkLogic(root, module string, contract *contractSnapshot, seeds map[string][]byte) ([]Diagnostic, map[string]logicMethod) {
groups := map[string][]routeContract{}
knownTypes := map[string]bool{}
for _, r := range contract.Routes {
if !r.Manual {
groups[r.Group] = append(groups[r.Group], r)
}
for name := range r.Types {
knownTypes[name] = true
}
}
// Project-owned aliases may make differently named DTOs identical. Let
// the compiler handle such cases rather than rejecting valid Go aliases.
typeExtensions := false
if entries, err := os.ReadDir(filepath.Join(root, "internal/types")); err == nil {
for _, e := range entries {
if sourceFile(e.Name()) && !ownedPath("internal/types/"+e.Name()) {
typeExtensions = true
}
}
}
var diagnostics []Diagnostic
locations := map[string]logicMethod{}
for group, routes := range groups {
directory := "internal/logic/" + group
files := map[string][]byte{}
uncertain := false
entries, err := os.ReadDir(filepath.Join(root, filepath.FromSlash(directory)))
if err != nil && !errors.Is(err, os.ErrNotExist) {
diagnostics = append(diagnostics, Diagnostic{Severity: "error", Code: "logicUnreadable", Path: directory, Message: err.Error()})
continue
}
for _, e := range entries {
if e.IsDir() || !sourceFile(e.Name()) {
continue
}
name := directory + "/" + e.Name()
if err := safePath(root, name); err != nil {
diagnostics = append(diagnostics, Diagnostic{Severity: "error", Code: "logicUnreadable", Path: name, Message: err.Error()})
uncertain = true
continue
}
data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(name)))
if err != nil {
diagnostics = append(diagnostics, Diagnostic{Severity: "error", Code: "logicUnreadable", Path: name, Message: err.Error()})
uncertain = true
continue
}
files[name] = data
}
for name, data := range seeds {
if filepath.ToSlash(filepath.Dir(name)) == directory && sourceFile(filepath.Base(name)) {
if _, exists := files[name]; !exists {
files[name] = data
}
}
}
methods := map[string][]logicMethod{}
var names []string
for name := range files {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
data := files[name]
// Do not guess the user's GOFLAGS/build-tag combination. Flag the
// group for compiler verification if conditional files are present.
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(name))); err == nil {
match, err := build.Default.MatchFile(filepath.Join(root, filepath.FromSlash(directory)), filepath.Base(name))
if err != nil || !match {
uncertain = true
continue
}
}
positions := gotoken.NewFileSet()
file, err := goparser.ParseFile(positions, name, data, goparser.SkipObjectResolution|goparser.ParseComments)
if err != nil {
d := Diagnostic{Severity: "error", Code: "logicSyntax", Path: name, Message: "cannot parse Logic source: " + err.Error()}
if list, ok := err.(scanner.ErrorList); ok && len(list) > 0 {
d.Line = list[0].Pos.Line
}
diagnostics = append(diagnostics, d)
uncertain = true
continue
}
conditional := false
for _, group := range file.Comments {
for _, comment := range group.List {
if comment.Pos() < file.Package && (strings.HasPrefix(comment.Text, "//go:build ") || strings.HasPrefix(comment.Text, "// +build ")) {
conditional = true
}
}
}
if conditional {
uncertain = true
continue
}
imports := map[string]string{}
for _, imp := range file.Imports {
path, err := strconv.Unquote(imp.Path.Value)
if err != nil {
continue
}
alias := filepath.Base(path)
if imp.Name != nil {
alias = imp.Name.Name
}
imports[alias] = path
}
for _, decl := range file.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok || fn.Recv == nil || len(fn.Recv.List) != 1 {
continue
}
receiver := fn.Recv.List[0].Type
if pointer, ok := receiver.(*ast.StarExpr); ok {
receiver = pointer.X
}
id, ok := receiver.(*ast.Ident)
if !ok || id.Name != "Logic" {
continue
}
params, a := canonicalFields(fn.Type.Params, imports, module, knownTypes, typeExtensions)
results, b := canonicalFields(fn.Type.Results, imports, module, knownTypes, typeExtensions)
methods[fn.Name.Name] = append(methods[fn.Name.Name], logicMethod{name, displaySignature(fn.Name.Name, params, results), positions.Position(fn.Pos()).Line, a && b})
}
}
for _, r := range routes {
found := methods[upper(r.Handler)]
if len(found) == 1 && !uncertain {
locations[r.file()] = found[0]
}
d := Diagnostic{Path: r.file(), Operation: r.operation(), After: r.Signature}
switch {
case uncertain:
d.Severity, d.Code, d.Message = "review", "logicUnchecked", "conditional or unreadable source prevents a complete signature check; run go test ./... with your build settings"
case len(found) == 0:
d.Severity, d.Code, d.Message = "review", "logicNotFound", "no direct Logic method found; check embedded/aliased implementations with go test ./..."
case len(found) > 1:
d.Severity, d.Code, d.Message = "error", "logicDuplicate", "multiple Logic methods have this name"
for _, m := range found {
d.Details = append(d.Details, fmt.Sprintf("%s:%d", m.path, m.line))
}
case !found[0].known:
d.Severity, d.Code, d.Message = "review", "logicUnchecked", "signature uses project-specific type names; verify aliases with go test ./..."
d.Path, d.Line, d.Before = found[0].path, found[0].line, found[0].signature
case found[0].signature != r.Signature:
d.Severity, d.Code, d.Message = "error", "logicSignature", "preserved Logic signature differs from the generated call; update this method"
d.Path, d.Line, d.Before = found[0].path, found[0].line, found[0].signature
default:
continue
}
diagnostics = append(diagnostics, d)
}
}
return diagnostics, locations
}
func sourceFile(name string) bool {
return strings.HasSuffix(name, ".go") && !strings.HasSuffix(name, "_test.go") && !strings.HasPrefix(name, ".") && !strings.HasPrefix(name, "_")
}
func canonicalFields(fields *ast.FieldList, imports map[string]string, module string, knownTypes map[string]bool, extensions bool) ([]string, bool) {
var values []string
known := true
if fields != nil {
for _, field := range fields.List {
value, ok := canonicalType(field.Type, imports, module, knownTypes, extensions)
known = known && ok
count := len(field.Names)
if count == 0 {
count = 1
}
for i := 0; i < count; i++ {
values = append(values, value)
}
}
}
return values, known
}
func canonicalType(expr ast.Expr, imports map[string]string, module string, knownTypes map[string]bool, extensions bool) (string, bool) {
switch e := expr.(type) {
case *ast.ParenExpr:
return canonicalType(e.X, imports, module, knownTypes, extensions)
case *ast.StarExpr:
value, ok := canonicalType(e.X, imports, module, knownTypes, extensions)
return "*" + value, ok
case *ast.Ellipsis:
value, ok := canonicalType(e.Elt, imports, module, knownTypes, extensions)
return "..." + value, ok
case *ast.Ident:
switch e.Name {
case "error", "bool", "string", "int", "int8", "int16", "int32", "int64", "uint", "uint8", "uint16", "uint32", "uint64", "uintptr", "float32", "float64", "complex64", "complex128", "any":
return e.Name, true
}
case *ast.SelectorExpr:
if pkg, ok := e.X.(*ast.Ident); ok {
switch imports[pkg.Name] {
case "context":
return "context." + e.Sel.Name, true
case frameworkModule + "/httpx":
return "httpx." + e.Sel.Name, true
case module + "/internal/types":
return "types." + e.Sel.Name, knownTypes[e.Sel.Name] || !extensions
}
}
}
var b bytes.Buffer
_ = format.Node(&b, gotoken.NewFileSet(), expr)
return b.String(), false
}

View File

@@ -0,0 +1,218 @@
package apigen
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func TestManagedDefaultValidation(t *testing.T) {
source := readFile(t, filepath.Join(frameworkRoot(t), "examples/contracts/managed.api"))
spec, err := parseText(t, source)
if err != nil {
t.Fatal(err)
}
c, err := Compile(spec)
if err != nil {
t.Fatal(err)
}
if spec.Routes[0].ResponseFormat != "" {
t.Fatal("compile mutated source")
}
var doc map[string]any
if err = json.Unmarshal(c.Document, &doc); err != nil {
t.Fatal(err)
}
if doc["x-omni-default-response"] != "CommonResponse" {
t.Fatal("missing default metadata")
}
for _, bad := range []string{
strings.Replace(source, "defaultResponse: CommonResponse", "defaultResponse: Missing", 1),
source + "\n@app (defaultResponse: CommonResponse)\n",
strings.Replace(source, "maxBytes: 2048", "maxBytes: 0", 1),
strings.Replace(source, "@produces \"text/plain\"", "@produces \"text/plain; charset=utf-8\"", 1),
} {
s, err := parseText(t, bad)
if err == nil {
_, err = Compile(s)
}
if err == nil {
t.Fatal("invalid contract accepted")
}
}
root := t.TempDir()
writeFile(t, filepath.Join(root, "service.api"), "import \"child.api\"\nservice demo {\n@handler hello\nget /hello\n}")
writeFile(t, filepath.Join(root, "child.api"), "@app (defaultResponse: Shared)\nresponse Shared ()")
if _, err = Parse(filepath.Join(root, "service.api")); err == nil {
t.Fatal("import owns App defaults")
}
}
func TestManagedGeneratedProject(t *testing.T) {
root := frameworkRoot(t)
dir := filepath.Join(t.TempDir(), "managed")
o := Options{Dir: dir, API: filepath.Join(root, "examples/contracts/managed.api"), Module: "example.com/managed", FrameworkDir: root}
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
o.API = filepath.Join(dir, "api/managed.api")
for _, path := range []string{"cmd/server/main.go", "internal/settings", "internal/servicecontext", "internal/service"} {
if _, err := os.Stat(filepath.Join(dir, path)); !os.IsNotExist(err) {
t.Fatalf("old layout remains: %s", path)
}
}
mainBefore := readFile(t, filepath.Join(dir, "main.go"))
files := map[string]string{
"consumer_test.go": "consumer_test.go", "internal/svc/options.go": "options.go", "internal/svc/client.go": "client.go", "internal/logic/demo/check.go": "operations.go",
"shutdown_test.go": "shutdown_test.go",
"phases_test.go": "phases_test.go", "internal/svc/background.go": "background.go",
}
for path, fixture := range files {
writeFile(t, filepath.Join(dir, path), readFile(t, filepath.Join("testdata/managed", fixture)))
}
for _, name := range []string{"other", "rawJSON", "upload", "download", "callback", "slow"} {
writeFile(t, filepath.Join(dir, "internal/logic/demo", name+".go"), "package demo\n")
}
configPath := filepath.Join(dir, "internal/config/config.go")
writeFile(t, configPath, strings.Replace(readFile(t, configPath), "type Config struct {", "type Config struct {\n BackgroundMode string `json:\"backgroundMode\"`\n FailInit bool `json:\"failInit\"`\n ShutdownInit bool `json:\"shutdownInit\"`", 1))
svcPath := filepath.Join(dir, "internal/svc/serviceContext.go")
source := strings.Replace(readFile(t, svcPath), "type ServiceContext struct {", "type ServiceContext struct {\n Client *Client", 1)
source = strings.Replace(source, "return nil", "return initClient(ctx,c)", 1)
writeFile(t, svcPath, source)
// Independent tooling stays outside generator ownership.
writeFile(t, filepath.Join(dir, "cmd/migrate/main.go"), "package main\nfunc main(){}\n")
before := snapshot(t, dir)
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("regeneration changed handwritten files")
}
o.Check = true
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
o.Check = false
// Add an API endpoint: handler and Logic stub appear without touching main or business code.
api := readFile(t, o.API)
writeFile(t, o.API, api+"\n@server (group: extra)\nservice managed-api {\n@handler extra\nget /extra\n}\n")
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
if readFile(t, filepath.Join(dir, "main.go")) != mainBefore {
t.Fatal("main requires business-specific edits")
}
for path := range files {
if readFile(t, filepath.Join(dir, path)) != string(before[path]) {
t.Fatal("business file changed", path)
}
}
// Main is owned, checked, and restored when missing. Hand edits are a conflict.
writeFile(t, filepath.Join(dir, "main.go"), mainBefore+"\n// drift\n")
o.Check = true
if err := generateManagedTestProject(t, o); err == nil {
t.Fatal("main drift accepted")
}
o.Check = false
if err := os.Remove(filepath.Join(dir, "main.go")); err != nil {
t.Fatal(err)
}
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
o.Check = true
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
for _, args := range [][]string{{"test", "-race", "-mod=mod", "-count=1", "./..."}, {"build", "-mod=mod", "."}} {
cmd := exec.CommandContext(ctx, "go", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("%v: %v\n%s", args, err, out)
}
}
if os.Getenv("GO_OMNI_RELEASE_CLI") != "" {
cmd := exec.CommandContext(ctx, "go", "list", "-m", "-json", frameworkModule)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off")
data, err := cmd.CombinedOutput()
if err != nil {
t.Fatal(err, string(data))
}
var dependency struct {
Version, Sum string
Replace any
}
if err = json.Unmarshal(data, &dependency); err != nil {
t.Fatal(err)
}
if dependency.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || dependency.Replace != nil || dependency.Sum == "" {
t.Fatal("not a fixed remote consumer")
}
t.Logf("remote framework %s checksum %s; no local replace", dependency.Version, dependency.Sum)
}
}
func generateManagedTestProject(t *testing.T, o Options) error {
t.Helper()
cli := os.Getenv("GO_OMNI_RELEASE_CLI")
if cli == "" {
_, err := Generate(o)
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
args := []string{"api", "generate", "--api", o.API, "--dir", o.Dir, "--module", o.Module}
if o.Check {
args = append(args, "--check")
}
if o.DryRun {
args = append(args, "--dry-run")
}
cmd := exec.CommandContext(ctx, cli, args...)
cmd.Dir = filepath.Dir(o.Dir)
if data, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("released CLI: %w\n%s", err, data)
}
return nil
}
func TestManagedOwnershipRejectsOldOrHandwrittenEntry(t *testing.T) {
for _, oldLayout := range []bool{false, true} {
t.Run(map[bool]string{false: "handwrittenMain", true: "oldManifest"}[oldLayout], func(t *testing.T) {
dir := filepath.Join(t.TempDir(), "app")
if _, err := New(Options{Dir: dir, Module: "example.com/entry", FrameworkDir: frameworkRoot(t)}); err != nil {
t.Fatal(err)
}
var m manifest
if err := json.Unmarshal([]byte(readFile(t, filepath.Join(dir, manifestPath))), &m); err != nil {
t.Fatal(err)
}
if oldLayout {
m.Version = 1
} else {
delete(m.Generated, "main.go")
writeFile(t, filepath.Join(dir, "main.go"), "package main\nfunc main(){}\n")
}
data, _ := json.Marshal(m)
writeFile(t, filepath.Join(dir, manifestPath), string(data))
before := snapshot(t, dir)
if _, err := Generate(Options{Dir: dir, API: filepath.Join(dir, "api/service.api")}); err == nil {
t.Fatal("unowned layout accepted")
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("rejected migration modified project")
}
})
}
}

View File

@@ -0,0 +1,324 @@
package apigen
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
const declaredAPI = `syntax = "v1"
info (
title: "Declared API"
description: "Errors and authentication share one contract"
)
error (
Unauthenticated 401 "UNAUTHENTICATED" "authentication required"
Forbidden 403 "FORBIDDEN" "permission denied"
EmailTaken 409 "EMAIL_TAKEN" "email already in use"
NameTaken 409 "NAME_TAKEN" "name already in use"
)
auth UserAuth (
type: bearer
middleware: Auth
description: "Verified application token"
)
auth PartnerAuth (
type: apiKey
in: header
name: X-API-Key
middleware: Partner
)
auth SessionAuth (
type: apiKey
in: cookie
name: sessionId
middleware: Session
)
@server (
group: user
auth: UserAuth
errors: Unauthenticated
middleware: Audit
)
service declared-api {
@doc (
summary: "Create a user"
description: "Uniqueness is enforced in a transaction"
tags: users, administration
deprecated: true
)
@errors Forbidden, EmailTaken, NameTaken
@handler create
post /users
@auth none
@errors none
@handler public
get /public
@auth PartnerAuth
@handler partner
get /partner
@auth SessionAuth
@handler session
get /session
}
`
func document(t *testing.T, c *Compiled) map[string]any {
t.Helper()
var doc map[string]any
if err := json.Unmarshal(c.Document, &doc); err != nil {
t.Fatal(err)
}
return expandDocumentRefs(t, doc, doc).(map[string]any)
}
func operation(t *testing.T, doc map[string]any, path, method string) map[string]any {
t.Helper()
return doc["paths"].(map[string]any)[path].(map[string]any)[method].(map[string]any)
}
func TestDeclaredErrorsAndAuthentication(t *testing.T) {
spec, err := parseText(t, declaredAPI)
if err != nil {
t.Fatal(err)
}
c, err := Compile(spec)
if err != nil {
t.Fatal(err)
}
again, err := Compile(spec)
if err != nil || string(c.Document) != string(again.Document) {
t.Fatalf("compile is not repeatable: %v", err)
}
if strings.Join(spec.Routes[0].Middleware, ",") != "Audit" {
t.Fatal("parser output mutated")
}
if strings.Join(c.Spec.Routes[0].Middleware, ",") != "Auth,Audit" {
t.Fatal("authentication must be first")
}
if strings.Join(c.Spec.Routes[1].Middleware, ",") != "Audit" {
t.Fatal("public override retained automatic auth")
}
doc := document(t, c)
op := operation(t, doc, "/users", "post")
if op["deprecated"] != true || op["description"] == nil || len(op["tags"].([]any)) != 2 {
t.Fatalf("documentation missing: %v", op)
}
security := op["security"].([]any)
if _, ok := security[0].(map[string]any)["UserAuth"]; !ok {
t.Fatal("security requirement absent")
}
schemes := doc["components"].(map[string]any)["securitySchemes"].(map[string]any)
if len(schemes) != 3 || schemes["UserAuth"].(map[string]any)["scheme"] != "bearer" || schemes["SessionAuth"].(map[string]any)["in"] != "cookie" {
t.Fatalf("wrong security schemes: %v", schemes)
}
responses := op["responses"].(map[string]any)
for _, status := range []string{"401", "403", "409"} {
if responses[status] == nil {
t.Fatal("missing status " + status)
}
}
media := responses["409"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)
if len(media["examples"].(map[string]any)) != 2 {
t.Fatal("same-status errors were overwritten")
}
codes := media["schema"].(map[string]any)["properties"].(map[string]any)["code"].(map[string]any)["enum"].([]any)
if len(codes) != 2 {
t.Fatal("business code enum absent")
}
public := operation(t, doc, "/public", "get")
if len(public["security"].([]any)) != 0 || public["responses"].(map[string]any)["401"] != nil || public["x-omni-errors"] != nil {
t.Fatal("public overrides did not clear group declarations")
}
}
func TestInvalidDeclarations(t *testing.T) {
replacements := [][2]string{
{`"EMAIL_TAKEN"`, `"UNAUTHENTICATED"`},
{`EmailTaken 409`, `EmailTaken 204`},
{`EmailTaken 409`, `emailTaken 409`},
{`NameTaken 409`, `CodeEmailTaken 409`},
{`auth: UserAuth`, `auth: Unknown`},
{`errors: Unauthenticated`, `errors: Unknown`},
{`middleware: Audit`, `middleware: Auth`},
{`type: bearer`, `type: oauth2`},
{`in: header`, `in: query`},
{`name: X-API-Key`, `name: Authorization`},
{`deprecated: true`, `deprecated: yes`},
{`tags: users, administration`, `tags: users, users`},
{`@errors Forbidden, EmailTaken, NameTaken`, `@errors Forbidden, Forbidden`},
{`middleware: Auth`, `middleware: Defaults`},
}
for _, change := range replacements {
spec, err := parseText(t, strings.Replace(declaredAPI, change[0], change[1], 1))
if err == nil {
_, err = Compile(spec)
}
if err == nil {
t.Fatalf("accepted invalid %q", change[1])
}
if !strings.Contains(err.Error(), "service.api:") {
t.Fatalf("source position missing: %v", err)
}
}
}
func TestImportedErrorCatalog(t *testing.T) {
root := t.TempDir()
writeFile(t, filepath.Join(root, "errors.api"), `error Conflict 409 "CONFLICT" "conflict"`)
writeFile(t, filepath.Join(root, "service.api"), "import \"errors.api\"\nservice imported-api { @errors Conflict\n @handler create\n post /users\n }")
spec, err := Parse(filepath.Join(root, "service.api"))
if err != nil {
t.Fatal(err)
}
if _, err = Compile(spec); err != nil {
t.Fatal(err)
}
}
func TestDryRunAndEntryDiscovery(t *testing.T) {
root := t.TempDir()
source := filepath.Join(root, "input/service.api")
writeFile(t, source, declaredAPI)
dir := filepath.Join(root, "project")
o := Options{API: source, Dir: dir, Module: "example.com/dx", FrameworkDir: frameworkRoot(t), DryRun: true}
out, err := Generate(o)
if err != nil || len(out.Changes) == 0 {
t.Fatalf("empty plan: %v", err)
}
if _, err = os.Stat(dir); !os.IsNotExist(err) {
t.Fatal("dry-run created directory")
}
o.DryRun = false
if _, err = Generate(o); err != nil {
t.Fatal(err)
}
entry, err := ProjectEntry(dir)
if err != nil || entry != filepath.Join(dir, "api/service.api") {
t.Fatalf("entry: %s %v", entry, err)
}
o = Options{API: entry, Dir: dir, DryRun: true}
before := snapshot(t, dir)
out, err = Generate(o)
if err != nil || len(out.Changes) != 0 {
t.Fatalf("unchanged project plan: %v %+v", err, out.Changes)
}
writeFile(t, entry, strings.Replace(declaredAPI, "email already in use", "email is already registered", 1))
before = snapshot(t, dir)
out, err = Generate(o)
if err != nil {
t.Fatal(err)
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("dry-run changed project")
}
actions := map[string]string{}
for _, c := range out.Changes {
actions[c.Path] = c.Action
}
if actions["internal/apierrors/errorsGen.go"] != "update" || actions["docs/openapi.json"] != "update" {
t.Fatalf("missing plan: %v", actions)
}
o.Check = true
if _, err = Generate(o); err == nil {
t.Fatal("check and dry-run combined")
}
// A malformed manifest cannot redirect the shorthand outside its project.
writeFile(t, filepath.Join(dir, manifestPath), `{"version":1,"entry":"../input/service.api"}`)
if _, err = ProjectEntry(dir); err == nil {
t.Fatal("unsafe manifest entry accepted")
}
}
func TestDeclaredContractConsumer(t *testing.T) {
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") == "" {
t.Skip("consumer integration")
}
root := t.TempDir()
source := filepath.Join(root, "input/service.api")
writeFile(t, source, declaredAPI)
dir := filepath.Join(root, "project")
writeFile(t, filepath.Join(dir, generatorConfigFile), `{"middleware":"handlers"}`)
if _, err := Generate(Options{API: source, Dir: dir, Module: "example.com/dx", FrameworkDir: frameworkRoot(t)}); err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(dir, "contract_test.go"), declaredConsumerTest)
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "go", "test", "-mod=mod", "-race", "-count=1", "./...")
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off", "GOFLAGS=")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("consumer: %v\n%s", err, out)
}
}
const declaredConsumerTest = `package main_test
import (
"context";"errors";"io";"net/http/httptest";"strings";"testing"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/logx"
"example.com/dx/internal/apierrors"
"example.com/dx/internal/handler"
"example.com/dx/internal/svc"
)
func TestRuntimeAuthAndFreshErrors(t *testing.T){
first:=apierrors.EmailTaken().WithCause(errors.New("private"));second:=apierrors.EmailTaken()
if first==second||second.Cause!=nil||second.Code!=apierrors.CodeEmailTaken||second.Status!=409{t.Fatal("shared error instance")}
cfg:=logx.Default();cfg.Writer=io.Discard;logger,err:=logx.New(cfg);if err!=nil{t.Fatal(err)};defer logger.Close()
deps,err:=svc.New(context.Background(),logger);if err!=nil{t.Fatal(err)};defer deps.Close()
app:=omni.New();defer app.Close();if err=handler.RegisterRoutes(app,deps);err!=nil{t.Fatal(err)}
w:=httptest.NewRecorder();app.Engine().ServeHTTP(w,httptest.NewRequest("POST","/users",nil))
if w.Code!=501||!strings.Contains(w.Body.String(),"MIDDLEWARE_NOT_IMPLEMENTED"){t.Fatal("unimplemented auth did not fail closed")}
var called []string
deps.Middlewares["Auth"]=func(next httpx.Handler)httpx.Handler{return func(c *httpx.Context)error{called=append(called,"auth");if c.Header("Authorization")!="Bearer valid"{return apierrors.Unauthenticated()};return next(c)}}
deps.Middlewares["Audit"]=func(next httpx.Handler)httpx.Handler{return func(c *httpx.Context)error{called=append(called,"audit");return next(c)}}
app2:=omni.New();defer app2.Close();if err=handler.RegisterRoutes(app2,deps);err!=nil{t.Fatal(err)}
w=httptest.NewRecorder();app2.Engine().ServeHTTP(w,httptest.NewRequest("POST","/users",nil))
if w.Code!=401||strings.Join(called,",")!="auth"{t.Fatal("unauthenticated request reached other middleware")}
called=nil;req:=httptest.NewRequest("POST","/users",nil);req.Header.Set("Authorization","Bearer valid")
w=httptest.NewRecorder();app2.Engine().ServeHTTP(w,req)
if w.Code!=501||strings.Join(called,",")!="auth,audit"{t.Fatal("middleware order changed")}
called=nil;w=httptest.NewRecorder();app2.Engine().ServeHTTP(w,httptest.NewRequest("GET","/public",nil))
if w.Code!=501||strings.Join(called,",")!="audit"||strings.Contains(w.Body.String(),"MIDDLEWARE_NOT_IMPLEMENTED"){t.Fatal("public override retained auth")}
delete(deps.Middlewares,"Auth");app3:=omni.New();defer app3.Close()
if err=handler.RegisterRoutes(app3,deps);err==nil{t.Fatal("missing auth registration accepted")}
w=httptest.NewRecorder();app3.Engine().ServeHTTP(w,httptest.NewRequest("GET","/public",nil))
if w.Code!=404{t.Fatal("failed auth preflight registered partial routes")}
}
`
func TestAuthMetadataWithParameterNamePrefixes(t *testing.T) {
source := "type Req {\n ID int `path:\"id\"`\n IDLong int `path:\"idLong\"`\n}\nauth Gate (\n type: bearer\n middleware: Auth\n)\n@server (auth: Gate)\nservice prefix-api {\n @handler read\n get /items/:id/children/:idLong (Req)\n}"
spec, err := parseText(t, source)
if err != nil {
t.Fatal(err)
}
c, err := Compile(spec)
if err != nil {
t.Fatal(err)
}
op := operation(t, document(t, c), "/items/{id}/children/{idLong}", "get")
if len(op["security"].([]any)) != 1 {
t.Fatal("auth metadata lost")
}
}
// Existing semantic assertions resolve the standard local references emitted
// by the compiler. documentRefs_test additionally checks the actual references.
func expandDocumentRefs(t *testing.T, doc map[string]any, v any) any {
t.Helper()
switch v:=v.(type){
case map[string]any:
if ref,ok:=v["$ref"].(string);ok && strings.HasPrefix(ref,"#/components/") && len(v)==1 {
var target any=doc
for _,part:=range strings.Split(strings.TrimPrefix(ref,"#/"),"/"){m,ok:=target.(map[string]any);if !ok{t.Fatal("invalid reference",ref)};target=m[part]}
if target==nil{t.Fatal("dangling reference",ref)};return expandDocumentRefs(t,doc,target)
}
out:=map[string]any{};for k,x:=range v{out[k]=expandDocumentRefs(t,doc,x)};return out
case []any:
out:=make([]any,len(v));for i,x:=range v{out[i]=expandDocumentRefs(t,doc,x)};return out
default:return v
}
}

View File

@@ -0,0 +1,19 @@
package apigen
import "fmt"
func renderMiddlewareFactories(names []string, generated, seeds map[string][]byte) {
// All project-specific types live behind this project-owned interface. svc
// implements it; middleware never imports svc (and cannot form that cycle).
seeds["internal/middleware/dependencies.go"] = []byte("package middleware\n// Dependencies is implemented by *svc.ServiceContext. Add typed accessors here\n// and implement them on ServiceContext. Factories borrow dependencies; register\n// their cleanup in ServiceContext.Init, not in a middleware factory.\ntype Dependencies interface {}\n")
text := fmt.Sprintf("package middleware\nimport(\"fmt\";%q)\n// New constructs declared middleware after ServiceContext.Init succeeds.\nfunc New(deps Dependencies)(map[string]httpx.Middleware,error){\nresult:=map[string]httpx.Middleware{}\n", frameworkModule+"/httpx")
if len(names) == 0 {
text = fmt.Sprintf("package middleware\nimport %q\nfunc New(deps Dependencies)(map[string]httpx.Middleware,error){\nresult:=map[string]httpx.Middleware{}\n", frameworkModule+"/httpx")
}
for i, name := range names {
delete(seeds, "internal/middleware/"+lower(name)+".go")
text += fmt.Sprintf("m%d,err:=New%s(deps)\nif err!=nil{return nil,fmt.Errorf(%q,err)}\nif m%d==nil{return nil,fmt.Errorf(%q)}\nresult[%q]=m%d\n", i, upper(name), "middleware "+name+": %w", i, "middleware "+name+": factory returned nil", name, i)
seeds["internal/middleware/"+lower(name)+"Factory.go"] = []byte(fmt.Sprintf("package middleware\nimport(\"errors\";%q)\n// New%s is project-owned. Implement this factory once; registration is generated.\n// Dependencies have already been initialized. Do not acquire owned resources here.\nfunc New%s(deps Dependencies)(httpx.Middleware,error){\nreturn nil,errors.New(%q)\n}\n", frameworkModule+"/httpx", upper(name), upper(name), "implement "+name+" middleware factory"))
}
generated["internal/middleware/registryGen.go"] = []byte(generatedHeader + text + "return result,nil\n}\n")
}

728
cmd/go-omni/apigen/parse.go Normal file
View File

@@ -0,0 +1,728 @@
// Package apigen implements go-omni's deliberately bounded go-zero-style API
// dialect. It generates Omni handlers, never go-zero or transport dependencies.
package apigen
import (
"errors"
"fmt"
"git.yuntuo.hk/k2safe/go-omni/contract"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"text/scanner"
)
type Spec struct {
DocumentCatalog *apierrors.Catalog
DocumentErrors *DocumentErrors
SharedRoots map[string]SharedRoot
SourcePaths map[string]string
RequestPolicy contract.RequestPolicy
DefaultResponse string
appDeclared bool
Title, Version, Service, Description string
Errors []ErrorDefinition
ErrorCatalogs []string
Auth []AuthDefinition
Responses []ResponseDefinition
Scalars []ScalarDefinition
Types []Type
Routes []Route
Sources map[string][]byte // relative to the entry API's directory
Entry string
}
type Type struct {
Name string
Fields []Field
Position string
}
// An empty Type denotes a named field composition, expanded before compilation.
type Field struct{ Name, Type, Tag, Description string }
type Route struct {
BoundMultipart bool
MaxParts int
MaxFieldBytes, MaxFileBytes int64
RequestPolicy contract.RequestPolicy
Manual, CustomBinding bool
BodyMode, Produces string
MaxBodyBytes int64
Method, Path, Request, Response, Handler, Group, Summary string
Middleware []string
Auth string
ResponseFormat string
ErrorResponseFormat string
Errors []string
Description string
Tags []string
Deprecated bool
Status int
Position string
}
type token struct {
text, position string
comment string
kind rune
space bool
}
type parser struct {
tokens []token
at int
spec *Spec
imports []string
}
func Parse(path string) (*Spec, error) { return parseWithCatalogs(path, nil) }
func parseWithCatalogs(path string, catalogs map[string][]byte) (*Spec, error) {
entry, err := filepath.Abs(path)
if err != nil {
return nil, err
}
entry, err = filepath.EvalSymlinks(entry)
if err != nil {
return nil, err
}
root := filepath.Dir(entry)
shared, err := readSharedConfig(root)
if err != nil {
return nil, err
}
roots := shared.Roots
spec := &Spec{Version: "1.0.0", DocumentErrors: shared.Errors, SharedRoots: roots, SourcePaths: map[string]string{}, Sources: map[string][]byte{}, Entry: filepath.Base(entry)}
visiting, visited := map[string]bool{}, map[string]bool{}
total := 0
var visit func(string, string, int) error
visit = func(name, alias string, depth int) error {
if depth > 32 || len(visited)+len(visiting) >= 64 {
return errors.New("api: import limit exceeded")
}
real, err := filepath.EvalSymlinks(name)
if err != nil {
return err
}
sourceRoot := root
if alias != "" {
sourceRoot = roots[alias].Path
}
rel, err := sourceWithin(sourceRoot, real)
if err != nil {
return err
}
if alias != "" {
rel = "@" + alias + "/" + rel
}
if filepath.Ext(real) != ".api" {
return errors.New("api: expected .api file")
}
if visiting[real] {
return fmt.Errorf("api: circular import %s", rel)
}
if visited[real] {
return nil
}
visiting[real] = true
defer delete(visiting, real)
f, err := os.Open(real)
if err != nil {
return err
}
data, err := io.ReadAll(io.LimitReader(f, (1<<20)+1))
_ = f.Close()
if err != nil {
return err
}
total += len(data)
if len(data) > 1<<20 || total > 8<<20 {
return errors.New("api: source size limit exceeded")
}
tokens, err := lex(rel, string(data))
if err != nil {
return err
}
p := &parser{tokens: tokens, spec: spec}
if err = p.parse(); err != nil {
return err
}
spec.Sources[filepath.ToSlash(rel)] = data
spec.SourcePaths[filepath.ToSlash(rel)] = real
for _, imp := range p.imports {
if filepath.IsAbs(imp) || strings.Contains(imp, "\\") {
return errors.New("api: imports must use relative slash paths")
}
nextAlias := alias
next := filepath.Join(filepath.Dir(real), filepath.FromSlash(imp))
if strings.HasPrefix(imp, "@") {
var sub string
var ok bool
nextAlias, sub, ok = strings.Cut(strings.TrimPrefix(imp, "@"), "/")
declared, found := roots[nextAlias]
if !ok || !found || sub == "" || strings.HasPrefix(sub, "/") || strings.HasPrefix(sub, "../") || filepath.ToSlash(filepath.Clean(sub)) != sub {
return errors.New("api: import needs a declared @root and a clean relative path")
}
next = filepath.Join(declared.Path, filepath.FromSlash(sub))
}
if err = visit(next, nextAlias, depth+1); err != nil {
return err
}
}
visited[real] = true
return nil
}
if err = visit(entry, "", 0); err != nil {
return nil, err
}
if err = readDocumentCatalog(root, spec, catalogs); err != nil {
return nil, err
}
if err = validateSharedPins(spec); err != nil {
return nil, err
}
if spec.Service == "" || len(spec.Routes) == 0 {
return nil, errors.New("api: at least one service route required")
}
if spec.Title == "" {
spec.Title = spec.Service
}
return spec, nil
}
func lex(name, source string) ([]token, error) {
var s scanner.Scanner
s.Init(strings.NewReader(source))
s.Filename = name
s.Mode = scanner.ScanIdents | scanner.ScanInts | scanner.ScanStrings | scanner.ScanRawStrings | scanner.ScanComments
s.Whitespace ^= 1 << '\n'
var scanErr error
s.Error = func(s *scanner.Scanner, message string) {
if scanErr == nil {
scanErr = fmt.Errorf("%s: %s", s.Position, message)
}
}
var out []token
end := 0
pending := ""
lastLine := 0
lastToken := -1
for {
kind := s.Scan()
if kind == scanner.Comment {
comment := cleanComment(s.TokenText())
if lastToken >= 0 && s.Position.Line == lastLine {
out[lastToken].comment = joinComment(out[lastToken].comment, comment)
} else {
pending = joinComment(pending, comment)
}
end = s.Position.Offset + len(s.TokenText())
continue
}
current := token{text: s.TokenText(), position: s.Position.String(), kind: kind, space: s.Position.Offset > end}
if kind != '\n' {
current.comment = pending
pending = ""
lastLine = s.Position.Line
lastToken = len(out)
}
out = append(out, current)
end = s.Position.Offset + len(s.TokenText())
if kind == scanner.EOF {
break
}
}
return out, scanErr
}
func (p *parser) peek() string { return p.tokens[p.at].text }
func (p *parser) next() token {
t := p.tokens[p.at]
if t.kind != scanner.EOF {
p.at++
}
return t
}
func (p *parser) skip() {
for p.peek() == "\n" || p.peek() == ";" {
p.next()
}
}
func (p *parser) err(message string) error {
return fmt.Errorf("%s: %s (got %q)", p.tokens[p.at].position, message, p.peek())
}
func (p *parser) expect(s string) error {
if p.peek() != s {
return p.err("expected " + strconv.Quote(s))
}
p.next()
return nil
}
func (p *parser) ident() (string, error) {
if p.tokens[p.at].kind != scanner.Ident {
return "", p.err("expected identifier")
}
return p.next().text, nil
}
func (p *parser) quoted() (string, error) {
if p.tokens[p.at].kind != scanner.String {
return "", p.err("expected quoted string")
}
return strconv.Unquote(p.next().text)
}
func (p *parser) parse() error {
server := map[string]string{}
syntaxSeen := false
for p.skip(); p.tokens[p.at].kind != scanner.EOF; p.skip() {
switch p.peek() {
case "scalar":
if err := p.parseScalar(); err != nil {
return err
}
case "syntax":
if syntaxSeen {
return p.err("duplicate syntax")
}
syntaxSeen = true
p.next()
if err := p.expect("="); err != nil {
return err
}
v, err := p.quoted()
if err != nil {
return err
}
if v != "v1" {
return p.err("only syntax v1 supported")
}
case "info":
p.next()
m, err := p.metadata()
if err != nil {
return err
}
if _, a := m["desc"]; a {
if _, b := m["description"]; b {
return p.err("use only one of info desc and description")
}
}
for k, v := range m {
switch k {
case "title":
p.spec.Title = v
case "version":
p.spec.Version = v
case "desc", "description":
p.spec.Description = v
case "author", "email":
default:
return p.err("unsupported info property " + k)
}
}
case "import":
p.next()
if p.peek() == "(" {
p.next()
for p.skip(); p.peek() != ")"; p.skip() {
v, err := p.quoted()
if err != nil {
return err
}
p.imports = append(p.imports, v)
}
p.next()
} else {
v, err := p.quoted()
if err != nil {
return err
}
p.imports = append(p.imports, v)
}
case "errorCatalog":
if err := p.parseErrorCatalog(); err != nil {
return err
}
case "error":
if err := p.parseErrors(); err != nil {
return err
}
case "auth":
if err := p.parseAuth(); err != nil {
return err
}
case "response":
if err := p.parseResponse(); err != nil {
return err
}
case "type":
p.next()
if p.peek() == "(" {
p.next()
for p.skip(); p.peek() != ")"; p.skip() {
if err := p.parseType(); err != nil {
return err
}
}
p.next()
} else {
if err := p.parseType(); err != nil {
return err
}
}
case "@":
p.next()
if p.peek() == "app" {
if p.spec.appDeclared {
return p.err("duplicate @app declaration")
}
// Defaults belong to the entry contract, never an imported module.
if !strings.HasPrefix(p.tokens[p.at].position, p.spec.Entry+":") {
return p.err("@app must be declared in the entry API file")
}
p.next()
m, err := p.metadata()
if err != nil {
return err
}
if len(m) == 0 {
return p.err("@app requires defaultResponse or request")
}
for key, value := range m {
switch key {
case "defaultResponse":
case "request":
p.spec.RequestPolicy, err = decodeRequestPolicy(value)
if err != nil {
return err
}
default:
return p.err("unsupported @app property " + key)
}
}
p.spec.DefaultResponse = m["defaultResponse"]
p.spec.appDeclared = true
continue
}
if err := p.expect("server"); err != nil {
return err
}
if len(server) != 0 {
return p.err("duplicate server block")
}
m, err := p.metadata()
if err != nil {
return err
}
server = m
for k := range m {
if k != "prefix" && k != "group" && k != "middleware" && k != "auth" && k != "errors" && k != "response" {
return p.err("unsupported @server option " + k + "; wire authentication through named middleware")
}
}
case "service":
if err := p.parseService(server); err != nil {
return err
}
server = map[string]string{}
default:
return p.err("unsupported declaration")
}
}
if len(server) > 0 {
return p.err("@server must precede a service")
}
return nil
}
func (p *parser) metadata() (map[string]string, error) {
if err := p.expect("("); err != nil {
return nil, err
}
out := map[string]string{}
for p.skip(); p.peek() != ")"; p.skip() {
key, err := p.ident()
if err != nil {
return nil, err
}
if err = p.expect(":"); err != nil {
return nil, err
}
value := ""
if p.tokens[p.at].kind == scanner.String {
value, err = p.quoted()
if err != nil {
return nil, err
}
} else if p.peek() == "{" {
depth := 0
var object strings.Builder
for {
t := p.next()
if t.kind == scanner.EOF {
return nil, p.err("unterminated metadata object")
}
if t.text == "{" {
depth++
}
if t.text == "}" {
depth--
}
if t.space {
object.WriteByte(' ')
}
object.WriteString(t.text)
if depth == 0 {
break
}
}
value = object.String()
} else {
for p.peek() != "\n" && p.peek() != ";" && p.peek() != ")" && p.tokens[p.at].kind != scanner.EOF {
value += p.next().text
}
}
if value == "" {
return nil, p.err("metadata value required")
}
if _, exists := out[key]; exists {
return nil, p.err("duplicate metadata " + key)
}
out[key] = value
}
p.next()
return out, nil
}
func (p *parser) parseType() error {
pos := p.tokens[p.at].position
name, err := p.ident()
if err != nil {
return err
}
p.skip()
if err = p.expect("{"); err != nil {
return err
}
typ := Type{Name: name, Position: pos}
for p.skip(); p.peek() != "}"; p.skip() {
description := p.tokens[p.at].comment
name, err := p.ident()
if err != nil {
return err
}
expression := ""
for p.peek() != "\n" && p.peek() != ";" && p.peek() != "}" && p.tokens[p.at].kind != scanner.RawString && p.tokens[p.at].kind != scanner.EOF {
if p.peek() == "interface" {
p.next()
if p.expect("{") != nil || p.expect("}") != nil {
return p.err("only empty interface{} is supported; use any for dynamic JSON")
}
expression += "any"
} else {
expression += p.next().text
}
}
if expression == "" {
if p.tokens[p.at].kind == scanner.RawString {
return p.err("composed types cannot carry field tags")
}
typ.Fields = append(typ.Fields, Field{Name: name, Description: description})
continue
}
if p.tokens[p.at].kind != scanner.RawString {
return p.err("field source tag required")
}
tagToken := p.next()
description = joinComment(description, tagToken.comment)
tag, err := strconv.Unquote(tagToken.text)
if err != nil {
return err
}
typ.Fields = append(typ.Fields, Field{Name: name, Type: expression, Tag: tag, Description: description})
}
p.next()
p.spec.Types = append(p.spec.Types, typ)
return nil
}
func (p *parser) parseService(server map[string]string) error {
p.next()
name, err := p.ident()
if err != nil {
return err
}
for p.peek() == "-" {
p.next()
part, e := p.ident()
if e != nil {
return e
}
name += "-" + part
}
if p.spec.Service != "" && p.spec.Service != name {
return p.err("one service name per entry API is supported")
}
p.spec.Service = name
p.skip()
if err = p.expect("{"); err != nil {
return err
}
for p.skip(); p.peek() != "}"; p.skip() {
route := Route{ResponseFormat: server["response"], Auth: server["auth"], Errors: commaList(server["errors"]), Group: server["group"], Status: 200, Position: p.tokens[p.at].position}
if route.Group == "" {
route.Group = "api"
}
if server["middleware"] != "" {
route.Middleware = strings.Split(server["middleware"], ",")
}
seen := map[string]bool{}
for p.peek() == "@" {
p.next()
annotation, e := p.ident()
if e != nil {
return e
}
if seen[annotation] {
return p.err("duplicate route annotation")
}
seen[annotation] = true
switch annotation {
case "request":
var props map[string]string
props, e = p.metadata()
if e == nil {
route.RequestPolicy, e = parseRequestPolicy(props)
}
case "handlerMode":
mode, err := p.ident()
if err != nil {
return err
}
if mode != "manual" {
return p.err("handlerMode must be manual")
}
route.Manual = true
case "binding":
mode, err := p.ident()
if err != nil {
return err
}
if mode != "custom" {
return p.err("binding must be custom")
}
route.CustomBinding = true
case "produces":
route.Produces, e = p.quoted()
case "body":
e = p.parseBody(&route)
case "handler":
route.Handler, e = p.ident()
case "doc":
e = p.parseDoc(&route)
case "auth":
route.Auth, e = p.ident()
case "response":
var format string
format, e = p.ident()
if format == "raw" && route.ResponseFormat != "raw" {
route.ErrorResponseFormat = route.ResponseFormat
}
route.ResponseFormat = format
case "errors":
var names []string
names, e = p.names()
if len(names) == 1 && names[0] == "none" {
route.Errors = nil
} else {
route.Errors = mergeNames(route.Errors, names)
}
case "status":
route.Status, e = strconv.Atoi(p.next().text)
default:
return p.err("unsupported route annotation " + annotation)
}
if e != nil {
return e
}
p.skip()
}
if route.Handler == "" {
return p.err("@handler required")
}
route.Method, err = p.ident()
if err != nil {
return err
}
path := ""
for p.peek() != "\n" && p.peek() != ";" && p.peek() != "(" && p.peek() != "returns" && p.peek() != "}" && p.tokens[p.at].kind != scanner.EOF {
path += p.next().text
}
route.Path = server["prefix"] + path
if p.peek() == "(" {
route.Request, err = p.routeType()
if err != nil {
return err
}
}
if p.peek() == "returns" {
p.next()
route.Response, err = p.routeType()
if err != nil {
return err
}
}
if p.peek() != "\n" && p.peek() != ";" && p.peek() != "}" {
return p.err("unexpected route suffix")
}
if route.Response == "" && !seen["status"] && !route.Manual && route.Produces == "" {
route.Status = 204
}
p.spec.Routes = append(p.spec.Routes, route)
}
p.next()
return nil
}
func (p *parser) routeType() (string, error) {
if err := p.expect("("); err != nil {
return "", err
}
prefix := ""
if p.peek() == "[" {
p.next()
if err := p.expect("]"); err != nil {
return "", err
}
prefix = "[]"
} else if p.peek() == "*" {
p.next()
prefix = "*"
}
name, err := p.ident()
if err != nil {
return "", err
}
return prefix + name, p.expect(")")
}
func joinComment(a, b string) string {
if a == "" {
return b
}
if b == "" {
return a
}
return a + "\n" + b
}
func cleanComment(s string) string {
if strings.HasPrefix(s, "//") {
return strings.TrimSpace(strings.TrimPrefix(s, "//"))
}
s = strings.TrimSuffix(strings.TrimPrefix(s, "/*"), "*/")
lines := strings.Split(s, "\n")
for i, line := range lines {
lines[i] = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "*"))
}
return strings.TrimSpace(strings.Join(lines, "\n"))
}
func fieldTag(f Field) string {
tags, err := contract.ParseTags(f.Tag)
if err != nil || f.Description == "" || tags["description"] != "" {
return f.Tag
}
return f.Tag + " description:" + strconv.Quote(f.Description)
}

View File

@@ -0,0 +1,73 @@
package apigen
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
)
type projectProfile struct {
Database string `json:"database"`
Storage string `json:"storage"`
LogOutput string `json:"logOutput"`
LogFormat string `json:"logFormat"`
}
func (o Options) profile() projectProfile {
return projectProfile{o.Database, o.Storage, o.LogOutput, o.LogFormat}
}
// Infrastructure files belong to the developer. Persist the original selection
// so a plain regeneration cannot silently change adapters or repair a missing
// file with settings from another profile.
func resolveProfile(o *Options) error {
defaults := projectProfile{"none", "none", "stderr", "json"}
data, err := os.ReadFile(filepath.Join(o.Dir, manifestPath))
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
existing := err == nil
if existing {
var previous manifest
if err = json.Unmarshal(data, &previous); err != nil {
return err
}
if previous.Profile != nil {
defaults = *previous.Profile
}
}
if o.Database == "memory" {
o.Database = "none"
}
for _, item := range []struct {
name string
value *string
fallback string
}{
{"database", &o.Database, defaults.Database}, {"storage", &o.Storage, defaults.Storage},
{"log-output", &o.LogOutput, defaults.LogOutput}, {"log-format", &o.LogFormat, defaults.LogFormat},
} {
if *item.value == "" {
*item.value = item.fallback
} else if existing && *item.value != item.fallback {
return fmt.Errorf("--%s differs from the original project profile; infrastructure is developer-owned, generate a new project to compare and migrate it explicitly", item.name)
}
}
if o.Database != "none" && o.Database != "mysql" {
return errors.New("--database must be none, memory or mysql")
}
switch o.Storage {
case "none", "local", "s3", "r2", "oss":
default:
return errors.New("--storage must be none, local, s3, r2 or oss")
}
if o.LogOutput != "stderr" && o.LogOutput != "stdout" && o.LogOutput != "file" {
return errors.New("--log-output must be stdout, stderr or file")
}
if o.LogFormat != "json" && o.LogFormat != "text" {
return errors.New("--log-format must be json or text")
}
return nil
}

View File

@@ -0,0 +1,105 @@
package apigen
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func TestInfrastructureProfileOwnership(t *testing.T) {
dir := filepath.Join(t.TempDir(), "project")
o := Options{Dir: dir, Module: "example.com/profile", FrameworkDir: frameworkRoot(t), Database: "mysql", Storage: "local", LogOutput: "file"}
if _, err := New(o); err != nil {
t.Fatal(err)
}
o = Options{Dir: dir, API: filepath.Join(dir, "api/service.api"), Check: true}
if _, err := Generate(o); err != nil {
t.Fatal(err)
}
before := snapshot(t, dir)
for _, changed := range []Options{
{Database: "none"}, {Storage: "s3"}, {LogOutput: "stdout"}, {LogFormat: "text"},
} {
changed.Dir, changed.API = dir, o.API
if _, err := Generate(changed); err == nil {
t.Fatal("profile switch accepted")
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("profile rejection changed project")
}
}
// Restoring a missing seed must use the saved profile, not current defaults.
if err := os.Remove(filepath.Join(dir, "internal/svc/storage.go")); err != nil {
t.Fatal(err)
}
if _, err := Generate(o); err == nil {
t.Fatal("check accepted missing infrastructure")
}
o.Check = false
if _, err := Generate(o); err != nil {
t.Fatal(err)
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("profile reconstruction changed project")
}
}
func TestAPIInfrastructureConsumers(t *testing.T) {
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") == "" {
t.Skip("consumer module integration")
}
for _, db := range []string{"none", "mysql"} {
for _, storage := range []string{"none", "local", "s3", "r2", "oss"} {
t.Run(db+"/"+storage, func(t *testing.T) {
dir := filepath.Join(t.TempDir(), "project")
if _, err := New(Options{Dir: dir, Module: "example.com/profile", FrameworkDir: frameworkRoot(t), Database: db, Storage: storage, LogOutput: "file"}); err != nil {
t.Fatal(err)
}
if _, err := Generate(Options{Dir: dir, API: filepath.Join(dir, "api/service.api"), Check: true}); err != nil {
t.Fatal(err)
}
test := `package config
import("testing";"os";"strings")
func TestConfiguration(t *testing.T){
t.Setenv("GO_OMNI_DB_DATABASE","test");t.Setenv("GO_OMNI_DB_USER","test");t.Setenv("GO_OMNI_DB_PASSWORD","test")
t.Setenv("GO_OMNI_STORAGE_SIGNING_KEY","01234567890123456789012345678901")
t.Setenv("GO_OMNI_STORAGE_ACCESS_KEY","test");t.Setenv("GO_OMNI_STORAGE_SECRET_KEY","test")
t.Setenv("GO_OMNI_STORAGE_BUCKET","test");t.Setenv("GO_OMNI_STORAGE_ENDPOINT","ENDPOINT")
t.Setenv("GO_OMNI_STORAGE_ACCOUNT_ID","01234567890123456789012345678901")
cfg,err:=Load("../../etc/config.yaml");if err!=nil{t.Fatal(err)}
if cfg.Server.Log.File==""{t.Fatal("file logger absent")}
t.Setenv("GO_OMNI_LOG_OUTPUT","stdout");t.Setenv("GO_OMNI_SERVER_LOG_FILE","./implicit.log")
cfg,err=Load("../../etc/config.yaml");if err!=nil||cfg.Server.Log.File!=Default().Server.Log.File{t.Fatalf("implicit environment override: %v",err)}
raw,err:=os.ReadFile("../../etc/config.yaml");if err!=nil{t.Fatal(err)}
// Add explicit fields while keeping storage and DB requirements.
raw=[]byte(strings.Replace(string(raw),"server:\n","server:\n log:\n output: '${OUTPUT:-stdout}'\n file: ''\n rotation:\n enabled: false\n",1))
path:="explicit.yaml";if err=os.WriteFile(path,raw,0600);err!=nil{t.Fatal(err)};defer os.Remove(path)
cfg,err=Load(path);if err!=nil||cfg.Server.Log.Output!="stdout"||cfg.Server.Log.File!=""||cfg.Server.Log.Rotation.Enabled{t.Fatalf("explicit file override: %v",err)}
_=os.Getenv("unused")
}`
endpoint := ""
if storage == "s3" {
endpoint = "127.0.0.1:9000"
}
if storage == "oss" {
endpoint = "https://oss-cn-hangzhou.aliyuncs.com"
}
writeFile(t, filepath.Join(dir, "internal/config/profile_test.go"), strings.ReplaceAll(test, `"ENDPOINT"`, `"`+endpoint+`"`))
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "go", "test", "-mod=mod", "-race", "-count=1", "./...")
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off", "GOFLAGS=")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("%v\n%s", err, out)
}
})
}
}
}

View File

@@ -0,0 +1,35 @@
package apigen
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
// ProjectEntry discovers the API entry recorded by generation, never guessing
// which of several .api files is the service entry.
func ProjectEntry(directory string) (string, error) {
root, err := filepath.Abs(directory)
if err != nil {
return "", err
}
if err = safePath(root, manifestPath); err != nil {
return "", err
}
data, err := os.ReadFile(filepath.Join(root, manifestPath))
if err != nil {
return "", fmt.Errorf("--api is required outside a generated project (no readable %s)", manifestPath)
}
var m manifest
if err = json.Unmarshal(data, &m); err != nil {
return "", fmt.Errorf("invalid API project manifest: %w", err)
}
if m.Version != 2 || filepath.Ext(m.Entry) != ".api" {
return "", fmt.Errorf("invalid API entry in %s; pass --api explicitly", manifestPath)
}
if err = safePath(root, m.Entry); err != nil {
return "", err
}
return filepath.Join(root, filepath.FromSlash(m.Entry)), nil
}

View File

@@ -0,0 +1,154 @@
package apigen
import (
"encoding/json"
"errors"
"fmt"
"git.yuntuo.hk/k2safe/go-omni/contract"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"mime"
"strconv"
"strings"
)
func (p *parser) parseBody(r *Route) error {
props, err := p.metadata()
if err != nil {
return err
}
for key, value := range props {
switch key {
case "maxParts":
r.MaxParts, err = strconv.Atoi(value)
if err != nil || r.MaxParts < 1 || r.MaxParts > 10000 {
return p.err("maxParts must be 1..10000")
}
case "maxFieldBytes", "maxFileBytes":
limit, e := strconv.ParseInt(value, 10, 64)
if e != nil || limit < 1 || limit > 1<<30 {
return p.err("part limits must be 1..1073741824 bytes")
}
if key == "maxFieldBytes" {
r.MaxFieldBytes = limit
} else {
r.MaxFileBytes = limit
}
case "mode":
r.BodyMode = value
case "maxBytes":
r.MaxBodyBytes, err = strconv.ParseInt(value, 10, 64)
if err != nil || r.MaxBodyBytes <= 0 || r.MaxBodyBytes > 1<<40 {
return p.err("maxBytes must be 1..1099511627776 bytes")
}
default:
return fmt.Errorf("unknown body property %s", key)
}
}
if r.MaxBodyBytes == 0 {
r.MaxBodyBytes = contract.DefaultMaxBodyBytes
}
return nil
}
func validateRouteExtensions(r Route) error {
if (r.MaxParts > 0 || r.MaxFieldBytes > 0 || r.MaxFileBytes > 0) && r.BodyMode != "multipart" {
return errors.New("part limits require multipart mode")
}
if err := r.RequestPolicy.Validate(); err != nil {
return err
}
if (r.BodyMode == "raw" || r.BodyMode == "multipart") && r.RequestPolicy.Body != "" && r.RequestPolicy.Body != "auto" {
return errors.New("raw/multipart cannot declare a JSON body policy")
}
if r.BodyMode != "" && r.BodyMode != "json" && r.BodyMode != "raw" && r.BodyMode != "multipart" {
return errors.New("body mode must be json, raw or multipart")
}
if r.CustomBinding && (r.Manual || r.Request == "") {
return errors.New("@binding custom requires an automatic handler with a request DTO")
}
if r.MaxBodyBytes > 0 && (r.Method == "get" || r.Method == "head") {
return errors.New("GET/HEAD cannot declare a body limit")
}
if (r.Method == "get" || r.Method == "head") && r.RequestPolicy.Body != "" && r.RequestPolicy.Body != "auto" && r.RequestPolicy.Body != "none" {
return errors.New("GET/HEAD cannot declare a JSON body policy")
}
if r.Produces != "" {
media, params, err := mime.ParseMediaType(r.Produces)
if err != nil || len(params) != 0 || media != r.Produces || strings.Contains(media, "*") {
return errors.New("produces requires a concrete media type without parameters")
}
if r.Response != "" || r.Status == 204 || r.Method == "head" {
return errors.New("@produces requires a stream response without a DTO, 204 or HEAD")
}
}
if r.Manual && r.Status == 204 && r.Response != "" {
return errors.New("204 cannot declare a response")
}
return nil
}
func decorateRequestOptions(op map[string]any, r Route) {
if r.BodyMode == "raw" || r.BodyMode == "multipart" {
r.RequestPolicy.Body = "auto"
}
op["x-omni-request-policy"] = r.RequestPolicy
if r.BodyMode == "multipart" {
limits, _ := (contract.MultipartOptions{MaxBodyBytes: r.MaxBodyBytes, MaxParts: r.MaxParts, MaxFieldBytes: r.MaxFieldBytes, MaxFileBytes: r.MaxFileBytes}).Normalized()
op["x-omni-multipart-limits"] = map[string]any{"maxBytes": limits.MaxBodyBytes, "maxParts": limits.MaxParts, "maxFieldBytes": limits.MaxFieldBytes, "maxFileBytes": limits.MaxFileBytes}
}
if r.BodyMode != "raw" && r.BodyMode != "multipart" {
if body, ok := op["requestBody"].(map[string]any); ok && r.RequestPolicy.Body == "optional" {
body["required"] = false
}
if _, ok := op["requestBody"]; !ok && (r.RequestPolicy.Body == "emptyObject" || r.RequestPolicy.Body == "required" || r.RequestPolicy.Body == "optional") {
op["requestBody"] = map[string]any{"required": r.RequestPolicy.Body != "optional", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object", "additionalProperties": false}}}}
}
}
if r.Manual {
op["x-omni-handler-mode"] = "manual"
}
if r.CustomBinding {
op["x-omni-binding"] = "custom"
}
if r.MaxBodyBytes > 0 {
op["x-omni-max-body-bytes"] = r.MaxBodyBytes
}
if r.BodyMode == "raw" || r.BodyMode == "multipart" {
media := "application/octet-stream"
schema := map[string]any{"type": "string", "format": "binary"}
if r.BodyMode == "multipart" {
media = "multipart/form-data"
schema = map[string]any{"type": "object", "additionalProperties": true}
}
op["requestBody"] = map[string]any{"required": true, "content": map[string]any{media: map[string]any{"schema": schema}}}
}
if r.Produces != "" {
schema := map[string]any{"type": "string"}
if r.Produces == "application/octet-stream" {
schema["format"] = "binary"
}
if r.Produces == "application/json" {
schema = map[string]any{}
}
op["responses"].(map[string]any)[strconv.Itoa(r.Status)] = map[string]any{"description": "Success", "content": map[string]any{r.Produces: map[string]any{"schema": schema}}}
}
}
func parseRequestPolicy(props map[string]string) (contract.RequestPolicy, error) {
data, err := json.Marshal(props)
if err != nil {
return contract.RequestPolicy{}, err
}
return decodeRequestPolicy(string(data))
}
func decodeRequestPolicy(raw string) (contract.RequestPolicy, error) {
var p contract.RequestPolicy
if err := apierrors.DecodeCatalogJSON([]byte(raw), &p); err != nil {
return p, fmt.Errorf("request policy: %w", err)
}
return p, p.Validate()
}
func policyLiteral(p contract.RequestPolicy) string {
return fmt.Sprintf("contract.RequestPolicy{UnknownQuery:%q,UTF8:%q,Charset:%q,Body:%q}", p.UnknownQuery, p.UTF8, p.Charset, p.Body)
}

View File

@@ -0,0 +1,126 @@
package apigen
import (
"bytes"
"encoding/json"
"fmt"
"io"
"strings"
"git.yuntuo.hk/k2safe/go-omni/response"
)
type ResponseDefinition struct {
Name string
Config response.Config
Position string
}
func (p *parser) parseResponse() error {
pos := p.next().position
name, err := p.ident()
if err != nil {
return err
}
p.skip()
props, err := p.metadata()
if err != nil {
return err
}
values := map[string]any{}
for key, value := range props {
switch key {
case "successCode", "errorCode":
if kind := props["codeType"]; kind == "integer" || kind == "int32" || kind == "int64" {
var number json.Number
if err = json.Unmarshal([]byte(value), &number); err != nil {
return fmt.Errorf("%s: %s must be an integer", pos, key)
}
values[key] = number
} else {
values[key] = value
}
case "codes", "messages", "body", "errorBody":
decoder := json.NewDecoder(strings.NewReader(value))
decoder.UseNumber()
var object map[string]any
if err = decoder.Decode(&object); err != nil || object == nil {
return fmt.Errorf("%s: %s must be a JSON object", pos, key)
}
var extra any
if decoder.Decode(&extra) != io.EOF {
return fmt.Errorf("%s: invalid %s mapping", pos, key)
}
values[key] = object
default:
values[key] = value
}
}
data, err := json.Marshal(values)
if err != nil {
return err
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.UseNumber()
decoder.DisallowUnknownFields()
var cfg response.Config
if err = decoder.Decode(&cfg); err != nil {
return fmt.Errorf("%s: response %s: %w", pos, name, err)
}
if err = cfg.Validate(); err != nil {
return fmt.Errorf("%s: %w", pos, err)
}
p.spec.Responses = append(p.spec.Responses, ResponseDefinition{Name: name, Config: cfg, Position: pos})
return nil
}
func responseConfig(spec *Spec, route Route) response.Config {
name := responseName(route)
for _, definition := range spec.Responses {
if definition.Name == name {
return definition.Config
}
}
return response.Config{}
}
func responseName(route Route) string {
if route.ResponseFormat == "raw" {
return route.ErrorResponseFormat
}
return route.ResponseFormat
}
func validateResponses(spec *Spec) error {
seen := map[string]bool{}
for _, definition := range spec.Responses {
if !exportedName.MatchString(definition.Name) || seen[definition.Name] {
return fmt.Errorf("%s: response template requires a unique UpperCamelCase name", definition.Position)
}
if err := definition.Config.Validate(); err != nil {
return fmt.Errorf("%s: %w", definition.Position, err)
}
seen[definition.Name] = true
}
if spec.DefaultResponse != "" && !seen[spec.DefaultResponse] {
return fmt.Errorf("unknown App default response template %s", spec.DefaultResponse)
}
for i := range spec.Routes {
r := &spec.Routes[i]
if r.ResponseFormat == "" {
r.ResponseFormat = spec.DefaultResponse
}
if r.ResponseFormat == "raw" && r.ErrorResponseFormat == "" {
r.ErrorResponseFormat = spec.DefaultResponse
}
}
for _, r := range spec.Routes {
if r.ResponseFormat != "" && r.ResponseFormat != "raw" && !seen[r.ResponseFormat] {
return fmt.Errorf("%s: unknown response template %s", r.Position, r.ResponseFormat)
}
if r.ErrorResponseFormat != "" && !seen[r.ErrorResponseFormat] {
return fmt.Errorf("%s: unknown error response template %s", r.Position, r.ErrorResponseFormat)
}
}
return nil
}

View File

@@ -0,0 +1,78 @@
package apigen
import (
"fmt"
"git.yuntuo.hk/k2safe/go-omni/contract"
"git.yuntuo.hk/k2safe/go-omni/openapi"
"reflect"
"strings"
)
// Response roots allow named DTOs, []DTO, []primitive, *DTO and null.
// A nil root slice is always encoded as an empty JSON array.
func responseShape(value string) (kind, name string) {
if value == "null" {
return "null", ""
}
if strings.HasPrefix(value, "[]") {
return "array", strings.TrimPrefix(value, "[]")
}
if strings.HasPrefix(value, "*") {
return "nullable", strings.TrimPrefix(value, "*")
}
return "object", value
}
func responseGoType(value string) string {
kind, name := responseShape(value)
if kind == "null" || value == "" {
return ""
}
if kind == "array" {
if primitiveTypes[name] != nil {
return "[]" + name
}
return "[]types." + name
}
return "*types." + name
}
func responseDataSchema(value string, bindings map[string]*contract.Binder) (*openapi.Schema, error) {
kind, name := responseShape(value)
if kind == "null" {
return &openapi.Schema{Type: "object", Nullable: true, Enum: []any{nil}}, nil
}
if kind == "array" && primitiveTypes[name] != nil {
if name == "File" {
return nil, fmt.Errorf("File is request-only; use a response DTO")
}
// Reuse the contract compiler, including integer bounds and byte-slice rules.
t := reflect.StructOf([]reflect.StructField{{Name: "Value", Type: reflect.SliceOf(primitiveTypes[name]), Tag: `json:"value"`}})
b, err := contract.Describe(t)
if err != nil {
return nil, err
}
s, err := b.ResponseSchema()
if err != nil {
return nil, err
}
return s.Properties["value"], nil
}
b, ok := bindings[name]
if !ok {
return nil, fmt.Errorf("unknown response DTO %q; use Name, []Name, []string/[]int64/[]bool/[]any, *Name or null", name)
}
s, err := b.ResponseSchema()
if err != nil {
return nil, err
}
switch kind {
case "array":
return &openapi.Schema{Type: "array", Items: s}, nil
case "nullable":
s.Nullable = true
}
return s, nil
}
var primitiveTypes = map[string]reflect.Type{"File": reflect.TypeOf(contract.File{}), "any": reflect.TypeOf((*any)(nil)).Elem(), "string": reflect.TypeOf(""), "bool": reflect.TypeOf(false), "int": reflect.TypeOf(int(0)), "int8": reflect.TypeOf(int8(0)), "int16": reflect.TypeOf(int16(0)), "int32": reflect.TypeOf(int32(0)), "int64": reflect.TypeOf(int64(0)), "uint": reflect.TypeOf(uint(0)), "uint8": reflect.TypeOf(uint8(0)), "uint16": reflect.TypeOf(uint16(0)), "uint32": reflect.TypeOf(uint32(0)), "uint64": reflect.TypeOf(uint64(0)), "float32": reflect.TypeOf(float32(0)), "float64": reflect.TypeOf(float64(0))}
func responseNeedsTypes(value string) bool { return strings.Contains(responseGoType(value), "types.") }

View File

@@ -0,0 +1,361 @@
package apigen
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
const configurableResponseAPI = `syntax = "v1"
response CommonResponse (
codeField: code
messageField: msg
dataField: data
requestIdField: "-"
codeType: integer
successCode: 200
errorCode: 9999
successMessage: "操作成功"
errorMessage: "系统错误"
dataMode: always
codes: {
"UNAUTHENTICATED": 401001,
"EMAIL_TAKEN": 409001
}
messages: {"UNAUTHENTICATED":"请先登录","EMAIL_TAKEN":"邮箱已存在"}
)
response PartnerResponse (
codeField: result
messageField: notice
dataField: payload
requestIdField: trace
successCode: "PASS"
)
error (
Unauthenticated 401 "UNAUTHENTICATED" "authentication required"
EmailTaken 409 "EMAIL_TAKEN" "email already taken"
)
auth Access (type: bearer
middleware: Auth
)
type ItemResponse {
Name string ` + "`json:\"name\"`" + `
}
@server (
group: items
response: CommonResponse
auth: Access
errors: Unauthenticated
)
service response-api {
@errors EmailTaken
@handler item
get /item returns (ItemResponse)
@response raw
@handler callback
post /callback returns (ItemResponse)
@response PartnerResponse
@handler partner
get /partner returns (ItemResponse)
}
`
func TestResponseTemplatesDocumentAndValidation(t *testing.T) {
spec, err := parseText(t, configurableResponseAPI)
if err != nil {
t.Fatal(err)
}
c, err := Compile(spec)
if err != nil {
t.Fatal(err)
}
doc := document(t, c)
getSchema := func(path, status string) map[string]any {
method := "get"
if path == "/callback" {
method = "post"
}
return operation(t, doc, path, method)["responses"].(map[string]any)[status].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any)
}
properties := getSchema("/item", "200")["properties"].(map[string]any)
if len(properties) != 3 || properties["code"].(map[string]any)["type"] != "integer" || properties["msg"] == nil || properties["requestId"] != nil {
t.Fatal(properties)
}
if getSchema("/callback", "200")["properties"].(map[string]any)["name"] == nil {
t.Fatal("raw JSON was wrapped")
}
if getSchema("/callback", "401")["properties"].(map[string]any)["msg"] == nil {
t.Fatal("raw override lost group error policy")
}
if getSchema("/partner", "200")["properties"].(map[string]any)["payload"] == nil {
t.Fatal("route override not applied")
}
conflict := operation(t, doc, "/item", "get")["responses"].(map[string]any)["409"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)
value := conflict["examples"].(map[string]any)["EmailTaken"].(map[string]any)["value"].(map[string]any)
if value["code"] != float64(409001) || value["msg"] != "邮箱已存在" || len(value) != 3 {
t.Fatal(value)
}
if got := conflict["schema"].(map[string]any)["properties"].(map[string]any)["code"].(map[string]any)["enum"].([]any); got[0] != float64(409001) {
t.Fatal(got)
}
for _, change := range [][2]string{{"response: CommonResponse", "response: Missing"}, {"messageField: msg", "messageField: code"}, {"codeType: integer", "codeType: boolean"}, {"successCode: 200", "successCode: 2.5"}, {"requestIdField: \"-\"", "unknownOption: value"}, {"@response raw", "@response missing"}} {
invalid, err := parseText(t, strings.Replace(configurableResponseAPI, change[0], change[1], 1))
if err == nil {
_, err = Compile(invalid)
}
if err == nil || !strings.Contains(err.Error(), "service.api:") {
t.Fatalf("bad declaration %s: %v", change[1], err)
}
}
}
func TestImportedResponseTemplate(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "common.api"), "response Shared (messageField: msg)\n")
writeFile(t, filepath.Join(dir, "service.api"), "import \"common.api\"\n@server (response: Shared)\nservice shared-api {\n@handler ping\nget /ping\n}")
spec, err := Parse(filepath.Join(dir, "service.api"))
if err != nil {
t.Fatal(err)
}
if _, err = Compile(spec); err != nil {
t.Fatal(err)
}
}
func TestResponseNestedBodyDocument(t *testing.T) {
definition := `response Nested (
body: {"success":"${ok}","result":"${data}","meta":{"status":"${status}","trace":"${requestId}"},"version":1}
errorBody: {"success":"${ok}","error":{"id":"${code}","reason":"${message}"}}
codeType: integer
successCode: 0
errorCode: 9999
codes: {"EMAIL_TAKEN":409001}
)
`
spec, err := parseText(t, definition+strings.Replace(configurableResponseAPI, "response: CommonResponse", "response: Nested", 1))
if err != nil {
t.Fatal(err)
}
compiled, err := Compile(spec)
if err != nil {
t.Fatal(err)
}
doc := document(t, compiled)
responses := operation(t, doc, "/item", "get")["responses"].(map[string]any)
success := responses["200"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any)["properties"].(map[string]any)
if success["code"] != nil || success["result"] == nil || success["success"].(map[string]any)["type"] != "boolean" {
t.Fatal(success)
}
media := responses["409"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)
schema := media["schema"].(map[string]any)["properties"].(map[string]any)
if schema["error"].(map[string]any)["properties"].(map[string]any)["id"].(map[string]any)["type"] != "integer" || schema["result"] != nil {
t.Fatal(schema)
}
example := media["examples"].(map[string]any)["EmailTaken"].(map[string]any)["value"].(map[string]any)
if example["success"] != false || example["error"].(map[string]any)["id"] != float64(409001) {
t.Fatal(example)
}
}
func TestResponseTemplateJSONTokens(t *testing.T) {
for _, tc := range []struct {
body string
valid bool
}{
{`{"scale":-1.5,"exponent":1e-3,"items":[true,null,"${data}"],"label":"a b"}`, true},
{`{"scale":1 2}`, false}, {`{"scale":- 12}`, false},
{`{"scale":tru e}`, false}, {`{"scale":1. 2}`, false},
} {
text := "response Shared (\nbody: " + tc.body + "\n)\n@server (response: Shared)\nservice test-api {\n@handler ping\nget /ping\n}\n"
spec, err := parseText(t, text)
if err == nil {
_, err = Compile(spec)
}
if (err == nil) != tc.valid {
t.Fatalf("%s: %v", tc.body, err)
}
}
}
func TestResponseCodeTypeDeclarations(t *testing.T) {
for _, kind := range []string{"int32", "int64"} {
source := strings.Replace(configurableResponseAPI, "codeType: integer", "codeType: "+kind, 1)
spec, err := parseText(t, source)
if err != nil {
t.Fatal(err)
}
compiled, err := Compile(spec)
if err != nil {
t.Fatal(err)
}
doc := document(t, compiled)
for _, status := range []string{"200", "401", "409", "500"} {
responses := operation(t, doc, "/item", "get")["responses"].(map[string]any)
schema := responses[status].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any)
code := schema["properties"].(map[string]any)["code"].(map[string]any)
if code["type"] != "integer" || code["format"] != kind {
t.Fatal(code)
}
}
for _, bad := range []string{"2.5", "9223372036854775808", "-9223372036854775809"} {
invalid := strings.Replace(source, "successCode: 200", "successCode: "+bad, 1)
if _, err := parseText(t, invalid); err == nil || !strings.Contains(err.Error(), "service.api:") {
t.Fatalf("%s %s: %v", kind, bad, err)
}
}
}
invalid := strings.Replace(configurableResponseAPI, "codeType: integer", "codeType: int32", 1)
invalid = strings.Replace(invalid, `"EMAIL_TAKEN": 409001`, `"EMAIL_TAKEN": 2147483648`, 1)
if _, err := parseText(t, invalid); err == nil {
t.Fatal("overflowing int32 mapping accepted")
}
}
func TestResponseTemplateConsumer(t *testing.T) {
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") == "" {
t.Skip("consumer integration")
}
dir := filepath.Join(t.TempDir(), "project")
source := filepath.Join(t.TempDir(), "service.api")
// Use an explicitly declared public body, rather than field-name shortcuts.
sourceText := strings.Replace(configurableResponseAPI, " codeField: code\n messageField: msg\n dataField: data\n requestIdField: \"-\"", ` body: {"code":"${code}","msg":"${message}","data":"${data}"}`, 1)
sourceText = strings.Replace(sourceText, "codeType: integer", "codeType: int64", 1)
sourceText = strings.Replace(sourceText, "successCode: 200", "successCode: 9223372036854775807", 1)
writeFile(t, source, sourceText)
releaseCLI := os.Getenv("GO_OMNI_RELEASE_CLI")
generate := func(api string, initial bool) {
t.Helper()
if releaseCLI != "" {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, releaseCLI, "api", "generate", "--api", api, "--dir", dir, "--module", "example.com/responseConsumer")
// The framework checkout intentionally enables local development mode.
// Run the installed CLI outside it to exercise remote-only consumption.
cmd.Dir = filepath.Dir(dir)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("released CLI: %v\n%s", err, output)
}
return
}
o := Options{API: api, Dir: dir}
if initial {
o.Module = "example.com/responseConsumer"
o.FrameworkDir = frameworkRoot(t)
}
if _, err := Generate(o); err != nil {
t.Fatal(err)
}
}
writeFile(t, filepath.Join(dir, generatorConfigFile), `{"middleware":"handlers"}`)
generate(source, true)
writeFile(t, filepath.Join(dir, "response_test.go"), responseConsumerTest)
for _, method := range []string{"Item", "Callback", "Partner"} {
body := `return &types.ItemResponse{Name:"` + strings.ToLower(method) + `"},nil`
if method == "Partner" {
body = `return nil,apierrors.EmailTaken()`
}
imports := `"context";"example.com/responseConsumer/internal/types"`
if method == "Partner" {
imports += `;"example.com/responseConsumer/internal/apierrors"`
}
writeFile(t, filepath.Join(dir, "internal/logic/items", strings.ToLower(method)+".go"), "package items\nimport("+imports+")\nfunc(s *Logic)"+method+"(ctx context.Context)(*types.ItemResponse,error){"+body+"}\n")
}
before := snapshot(t, dir)
generate(filepath.Join(dir, "api/service.api"), false)
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("regeneration changed hand-written files")
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "go", "test", "-mod=mod", "-race", "-count=1", "./...")
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off", "GOFLAGS=")
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("consumer: %v\n%s", err, output)
}
if releaseCLI != "" {
cmd = exec.CommandContext(ctx, "go", "list", "-m", "-json", frameworkModule)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off", "GOFLAGS=")
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("released module: %v\n%s", err, output)
}
var module struct {
Version, Sum string
Replace any
}
if err = json.Unmarshal(output, &module); err != nil {
t.Fatal(err)
}
if module.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || module.Replace != nil || module.Sum == "" {
t.Fatalf("expected fixed remote module without replacement: %+v", module)
}
t.Logf("remote framework %s checksum %s; no local replace", module.Version, module.Sum)
}
manifestBytes, err := os.ReadFile(filepath.Join(dir, manifestPath))
if err != nil {
t.Fatal(err)
}
var manifest manifest
if err = json.Unmarshal(manifestBytes, &manifest); err != nil {
t.Fatal(err)
}
if manifest.Generated["internal/handler/responseGen.go"] == "" {
t.Fatal("response policy not ownership-managed")
}
}
const responseConsumerTest = `package main_test
import (
"bytes";"encoding/json";"io";"net/http/httptest";"testing"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/logx"
"git.yuntuo.hk/k2safe/go-omni/response"
"example.com/responseConsumer/internal/apierrors"
"example.com/responseConsumer/internal/handler"
item "example.com/responseConsumer/internal/handler/items"
"example.com/responseConsumer/internal/types"
"context"
"example.com/responseConsumer/internal/svc"
)
type service struct{}
func(service)Item(context.Context)(*types.ItemResponse,error){return &types.ItemResponse{Name:"item"},nil}
func(service)Callback(context.Context)(*types.ItemResponse,error){return &types.ItemResponse{Name:"callback"},nil}
func(service)Partner(context.Context)(*types.ItemResponse,error){return nil,apierrors.EmailTaken()}
func TestResponseContract(t *testing.T){
app:=omni.New();defer app.Close()
cfg:=logx.Default();cfg.Writer=io.Discard;logger,err:=logx.New(cfg);if err!=nil{t.Fatal(err)};defer logger.Close()
deps,err:=svc.New(context.Background(),logger);if err!=nil{t.Fatal(err)};defer deps.Close()
deps.Middlewares["Auth"]=func(next httpx.Handler)httpx.Handler{return func(c *httpx.Context)error{if c.Header("Authorization")!="Bearer valid"{return apierrors.Unauthenticated()};return next(c)}}
if err=handler.RegisterRoutes(app,deps);err!=nil{t.Fatal(err)}
// Registered routes use the compiled policy before even running authentication.
for _,path:=range []string{"/item","/callback"}{
method:="GET";if path=="/callback"{method="POST"};w:=httptest.NewRecorder();app.ServeHTTP(w,httptest.NewRequest(method,path,nil))
var value map[string]any;json.Unmarshal(w.Body.Bytes(),&value)
if w.Code!=401||value["code"]!=float64(401001)||value["msg"]!="请先登录"||len(value)!=3{t.Fatalf("%s: %s",path,w.Body.String())}
}
// Execute the real generated registration and user-owned service files.
for _,tc:=range []struct{method,path string;status int;key string}{{"GET","/item",200,"data"},{"POST","/callback",200,"name"},{"GET","/partner",409,"result"}}{
w:=httptest.NewRecorder();r:=httptest.NewRequest(tc.method,tc.path,nil);r.Header.Set("Authorization","Bearer valid");app.ServeHTTP(w,r)
var value map[string]any;decoder:=json.NewDecoder(bytes.NewReader(w.Body.Bytes()));decoder.UseNumber();if err:=decoder.Decode(&value);err!=nil{t.Fatal(err)}
if w.Code!=tc.status||value[tc.key]==nil{t.Fatalf("generated %s: %d %s",tc.path,w.Code,w.Body.String())}
if tc.path=="/item"&&value["code"]!=json.Number("9223372036854775807"){t.Fatalf("generated int64 lost precision: %s",w.Body.String())}
}
// Business handlers also honor the same runtime policy and raw override.
h:=item.New(service{});runtime:=omni.New();defer runtime.Close()
common:=response.MustNew(response.Config{CodeType:"integer",SuccessCode:200,ErrorCode:9999,MessageField:"msg",RequestIDField:"-",DataMode:"always"})
runtime.GET("/item",h.Item,httpx.WithResponse(common))
runtime.POST("/callback",h.Callback,httpx.WithResponse(response.RawJSON(common)))
for _,tc:=range []struct{method,path,key string}{{"GET","/item","data"},{"POST","/callback","name"}}{
w:=httptest.NewRecorder();runtime.ServeHTTP(w,httptest.NewRequest(tc.method,tc.path,nil));var value map[string]any;json.Unmarshal(w.Body.Bytes(),&value)
if w.Code!=200||value[tc.key]==nil{t.Fatal(w.Body.String())}
}
}
`

View File

@@ -0,0 +1,74 @@
package apigen
import (
"fmt"
"git.yuntuo.hk/k2safe/go-omni/contract"
"golang.org/x/mod/module"
"reflect"
"strings"
)
type ScalarDefinition struct {
Name, GoImport, GoType, JSONInput, JSONOutput, Position string
Text bool
}
func (p *parser) parseScalar() error {
pos := p.next().position
name, err := p.ident()
if err != nil {
return err
}
p.skip()
props, err := p.metadata()
if err != nil {
return err
}
d := ScalarDefinition{Name: name, Position: pos}
for key, value := range props {
switch key {
case "goImport":
d.GoImport = value
case "goType":
d.GoType = value
case "jsonInput":
d.JSONInput = value
case "jsonOutput":
d.JSONOutput = value
case "text":
if value != "true" && value != "false" {
return p.err("text must be true or false")
}
d.Text = value == "true"
default:
return fmt.Errorf("%s: unknown scalar property %s", pos, key)
}
}
p.spec.Scalars = append(p.spec.Scalars, d)
return nil
}
func compileScalars(spec *Spec, types map[string]Type) (map[string]reflect.Type, []contract.Scalar, error) {
out := map[string]reflect.Type{}
var defs []contract.Scalar
goTypes := map[string]bool{}
for _, d := range spec.Scalars {
key := d.GoImport + "." + d.GoType
if d.Name == "ContractScalars" || !exportedName.MatchString(d.Name) || !exportedName.MatchString(d.GoType) || out[d.Name] != nil || types[d.Name].Name != "" || goTypes[key] {
return nil, nil, fmt.Errorf("%s: scalar names and Go types must be unique; names must be UpperCamelCase", d.Position)
}
if err := module.CheckImportPath(d.GoImport); err != nil || strings.Contains(d.GoImport, "\\") {
return nil, nil, fmt.Errorf("%s: invalid scalar goImport", d.Position)
}
// Unique stand-in types allow CLI schema checks without executing project code.
t := reflect.StructOf([]reflect.StructField{{Name: d.Name, Type: reflect.TypeOf(false)}})
def := contract.Scalar{Type: t, JSONInput: d.JSONInput, JSONOutput: d.JSONOutput, Text: d.Text}
probe := reflect.StructOf([]reflect.StructField{{Name: "Value", Type: t, Tag: `json:"value"`}})
if _, err := contract.Describe(probe, def); err != nil {
return nil, nil, fmt.Errorf("%s: %w", d.Position, err)
}
out[d.Name] = t
defs = append(defs, def)
goTypes[key] = true
}
return out, defs, nil
}

View File

@@ -0,0 +1,151 @@
package apigen
import (
"encoding/json"
"errors"
"fmt"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"io"
"os"
"path/filepath"
"sort"
"strings"
)
const importsFile = "omni.imports.json"
// SharedRoot explicitly authorizes reads from one canonical local directory.
// Version is a project-owned label; SHA256 optionally pins the imported source
// set, not a remote tag. The CLI never downloads or executes shared contracts.
type SharedRoot struct {
Path string `json:"path"`
Version string `json:"version,omitempty"`
SHA256 string `json:"sha256,omitempty"`
}
type sharedConfig struct {
Roots map[string]SharedRoot `json:"roots"`
Errors *DocumentErrors `json:"errors,omitempty"`
}
func readSharedConfig(root string) (sharedConfig, error) {
stat, err := os.Lstat(filepath.Join(root, importsFile))
if errors.Is(err, os.ErrNotExist) {
return sharedConfig{}, nil
}
if err != nil {
return sharedConfig{}, err
}
if !stat.Mode().IsRegular() {
return sharedConfig{}, errors.New("api: shared root configuration must be a regular file")
}
f, err := os.Open(filepath.Join(root, importsFile))
if errors.Is(err, os.ErrNotExist) {
return sharedConfig{}, nil
}
if err != nil {
return sharedConfig{}, err
}
defer f.Close()
raw, err := io.ReadAll(io.LimitReader(f, 65537))
if err != nil {
return sharedConfig{}, err
}
if len(raw) > 65536 {
return sharedConfig{}, errors.New("api: shared root config too large")
}
var cfg sharedConfig
if err = apierrors.DecodeCatalogJSON(raw, &cfg); err != nil {
return sharedConfig{}, err
}
if len(cfg.Roots) > 16 {
return sharedConfig{}, errors.New("api: at most 16 shared roots")
}
for name, r := range cfg.Roots {
if !lowerName.MatchString(name) || r.Path == "" || len(r.Version) > 128 {
return sharedConfig{}, errors.New("api: invalid shared root")
}
if r.SHA256 != "" && (len(r.SHA256) != 64 || strings.Trim(r.SHA256, "0123456789abcdef") != "") {
return sharedConfig{}, errors.New("api: shared sha256 must be 64 lowercase hex characters")
}
path := r.Path
if !filepath.IsAbs(path) {
path = filepath.Join(root, path)
}
r.Path, err = filepath.EvalSymlinks(path)
if err != nil {
return sharedConfig{}, err
}
r.Path, err = filepath.Abs(r.Path)
if err != nil {
return sharedConfig{}, err
}
stat, err := os.Stat(r.Path)
if err != nil {
return sharedConfig{}, err
}
if !stat.IsDir() {
return sharedConfig{}, errors.New("api: shared root must be a directory")
}
cfg.Roots[name] = r
}
return cfg, nil
}
func sourceWithin(root, name string) (string, error) {
real, err := filepath.EvalSymlinks(name)
if err != nil {
return "", err
}
rel, err := filepath.Rel(root, real)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", errors.New("api: imports must stay within their declared contract root")
}
return filepath.ToSlash(rel), nil
}
func sharedHash(sources map[string][]byte, alias string) string {
values := map[string]string{}
for name, data := range sources {
if strings.HasPrefix(name, "@"+alias+"/") {
values[strings.TrimPrefix(name, "@"+alias+"/")] = digest(data)
}
}
raw, _ := json.Marshal(values)
return digest(raw)
}
func sourceMetadata(spec *Spec) map[string]any {
hashes := map[string]string{}
for name, data := range spec.Sources {
hashes[name] = digest(data)
}
shared := map[string]any{}
for alias, r := range spec.SharedRoots {
shared[alias] = map[string]string{"version": r.Version, "sha256": sharedHash(spec.Sources, alias)}
}
return map[string]any{"files": hashes, "shared": shared}
}
func sharedSeeds(spec *Spec, directory string) ([]byte, error) {
cfg := sharedConfig{Roots: map[string]SharedRoot{}, Errors: spec.DocumentErrors}
for name, r := range spec.SharedRoots {
rel, err := filepath.Rel(filepath.Join(directory, "api"), r.Path)
if err != nil {
return nil, err
}
r.Path = filepath.ToSlash(rel)
cfg.Roots[name] = r
}
data, err := json.MarshalIndent(cfg, "", " ")
return append(data, '\n'), err
}
func validateSharedPins(spec *Spec) error {
names := make([]string, 0, len(spec.SharedRoots))
for name := range spec.SharedRoots {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
r := spec.SharedRoots[name]
if r.SHA256 != "" && r.SHA256 != sharedHash(spec.Sources, name) {
return fmt.Errorf("api: shared contract %s checksum differs (got %s)", name, sharedHash(spec.Sources, name))
}
}
return nil
}

View File

@@ -0,0 +1,96 @@
package apigen
import (
"bytes"
"encoding/json"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestControlledSharedContracts(t *testing.T) {
root := t.TempDir()
shared := filepath.Join(root, "shared")
writeFile(t, filepath.Join(shared, "response.api"), "response Common (codeType: int32; successCode: 0; errorCode: 9999; dataMode: always)\ntype Item { Name string `json:\"name\"` }\n")
for _, service := range []string{"api", "id"} {
dir := filepath.Join(root, service)
entry := filepath.Join(dir, "api/service.api")
writeFile(t, entry, "import \"@common/response.api\"\n@app(defaultResponse: Common)\nservice demo {\n@handler get\nget /get returns (Item)\n}\n")
config := `{"roots":{"common":{"path":"../../shared","version":"1.0.0"}}}`
writeFile(t, filepath.Join(dir, "api/omni.imports.json"), config)
spec, err := Parse(entry)
if err != nil {
t.Fatal(err)
}
if len(spec.Sources) != 2 || len(spec.SourcePaths) != 2 {
t.Fatalf("%+v", spec)
}
pin := sharedHash(spec.Sources, "common")
config = strings.Replace(config, `"version":"1.0.0"`, `"version":"1.0.0","sha256":"`+pin+`"`, 1)
writeFile(t, filepath.Join(dir, "api/omni.imports.json"), config)
o := Options{Dir: dir, API: entry, Module: "example.com/" + service, FrameworkDir: frameworkRoot(t)}
if _, err = Generate(o); err != nil {
t.Fatal(err)
}
o.Check = true
if _, err = Generate(o); err != nil {
t.Fatal(err)
}
if _, err = os.Stat(filepath.Join(dir, "api/@common")); !os.IsNotExist(err) {
t.Fatal("shared contract copied into service")
}
document := readFile(t, filepath.Join(dir, "docs/openapi.json"))
if !strings.Contains(document, pin) || !strings.Contains(document, "@common/response.api") {
t.Fatal("missing provenance")
}
writeFile(t, filepath.Join(dir, "api/omni.imports.json"), strings.Replace(config, pin, strings.Repeat("0", 64), 1))
if _, err = Parse(entry); err == nil {
t.Fatal("checksum mismatch accepted")
}
}
// An undeclared alias, traversal, symlink escape and cycle are all rejected.
for _, imp := range []string{"@missing/response.api", "@common/../outside.api", "../shared/response.api", "@common/escape.api", "@common/cycle.api"} {
local := filepath.Join(root, "consumer")
writeFile(t, filepath.Join(local, "omni.imports.json"), `{"roots":{"common":{"path":"../shared"}}}`)
writeFile(t, filepath.Join(root, "outside.api"), "type Outside {}\n")
_ = os.Symlink(filepath.Join(root, "outside.api"), filepath.Join(shared, "escape.api"))
writeFile(t, filepath.Join(shared, "cycle.api"), "import \"cycle.api\"\n")
entry := filepath.Join(local, "service.api")
writeFile(t, entry, "import "+strconvQuote(imp)+"\nservice demo{\n@handler get\nget /get\n}\n")
if _, err := Parse(entry); err == nil {
t.Fatal("unsafe import accepted", imp)
}
}
}
func strconvQuote(value string) string { data, _ := json.Marshal(value); return string(data) }
func TestCatalogDocumentExportDoesNotGenerateRuntimeRegistry(t *testing.T) {
dir := t.TempDir()
entry := filepath.Join(dir, "service.api")
writeFile(t, entry, "@app(defaultResponse: Common)\nresponse Common(codeType: int32; successCode: 0; errorCode: 9999; messageField: msg; dataMode: always)\nservice demo{\n@handler get\nget /get returns (null)\n}\n")
var exported bytes.Buffer
err := apierrors.ExportCatalog(&exported, apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{"TOO_LONG": {Code: 400002, Message: "{label}不能超过{max}个字符", Params: []apierrors.Parameter{{Name: "label", Type: "string", Example: "企业名称"}, {Name: "max", Type: "int", Example: 100}}}}})
if err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(dir, "omni.errors.json"), exported.String())
spec, err := Parse(entry)
if err != nil {
t.Fatal(err)
}
c, err := Compile(spec)
if err != nil {
t.Fatal(err)
}
if len(c.Spec.Errors) != 0 || !strings.Contains(string(c.Document), "企业名称不能超过100个字符") {
t.Fatal(string(c.Document))
}
target := filepath.Join(t.TempDir(), "catalog")
if _, err = Generate(Options{Dir: target, API: entry, Module: "example.com/catalog", FrameworkDir: frameworkRoot(t)}); err != nil {
t.Fatal(err)
}
if _, err = os.Stat(filepath.Join(target, "internal/apierrors/errorsGen.go")); !os.IsNotExist(err) {
t.Fatal("document metadata became a runtime error registry")
}
}

View File

@@ -0,0 +1,71 @@
package apigen
import (
"encoding/json"
"fmt"
"strings"
)
func appSource(o Options, spec *Spec) string {
source := fmt.Sprintf(`package handler
import (
"fmt"
omni %q
%q
%q
%q
%q
)
// NewApp is generated HTTP composition. The caller owns deps and its logger.
func NewApp(deps *svc.ServiceContext)(*omni.App,error){
if deps==nil || deps.Lifecycle==nil{return nil,fmt.Errorf("service context and lifecycle controller required; see PROJECT.md upgrade instructions")}
cfg:=deps.Config.Server
cfg.LogInstance=deps.Logger
for _,option:=range deps.AppOptions(){if option!=nil{option(&cfg)}}
`, frameworkModule, frameworkModule+"/httpx", frameworkModule+"/config", frameworkModule+"/health", o.Module+"/internal/svc")
source += fmt.Sprintf("cfg.Request=contract.MergePolicy(cfg.Request,%#v)\n", spec.RequestPolicy)
source = strings.Replace(source, "\"fmt\"", "\"fmt\";"+fmt.Sprintf("%q", frameworkModule+"/contract"), 1)
if spec.DefaultResponse != "" {
for _, r := range spec.Responses {
if r.Name == spec.DefaultResponse {
data, _ := json.Marshal(r.Config)
source += fmt.Sprintf("cfg.Response=newResponseTemplate(%q).Config();cfg.ResponseEncoder=nil\n", string(data))
}
}
}
source += "if err:=cfg.Validate();err!=nil{return nil,err}\n"
source += "if err:=cfg.Endpoints.ValidateRoutes([]config.EndpointRoute{\n"
for _, route := range spec.Routes {
source += fmt.Sprintf("{Method:%q,Path:%q},\n", route.Method, route.Path)
}
if o.Storage == "local" {
source += "{Method: \"GET\",Path: \"/storage/object\"},\n"
}
source += `}...);err!=nil{return nil,err}
if (cfg.Endpoints.Liveness.Enabled || cfg.Endpoints.Readiness.Enabled) && deps.Health==nil{return nil,fmt.Errorf("enabled health endpoints require a checker")}
app:=omni.New(omni.WithConfig(cfg))
app.Use(deps.GlobalMiddleware()...)
if err:=RegisterRoutes(app,deps);err!=nil{_ = app.Close();return nil,err}
if cfg.Endpoints.OpenAPI.Enabled{app.GET(cfg.Endpoints.OpenAPI.Path,OpenAPI)}
if cfg.Endpoints.Liveness.Enabled{
var options []health.HandlerOption
if cfg.Endpoints.Liveness.ResponseMode=="raw"{options=append(options,health.WithRawResponse())}
app.GET(cfg.Endpoints.Liveness.Path,deps.Health.LivenessHandler(options...))
}
if cfg.Endpoints.Readiness.Enabled{
options:=[]health.HandlerOption{health.WithShutdownContext(deps.Lifecycle.Context())}
if cfg.Endpoints.Readiness.ResponseMode=="raw"{options=append(options,health.WithRawResponse())}
app.GET(cfg.Endpoints.Readiness.Path,deps.Health.ReadinessHandler(options...))
}
`
if o.Storage == "local" {
source += `if deps.FileHTTP==nil{return nil,fmt.Errorf("local storage handler required")}
for _,method:=range []string{"GET","HEAD","PUT"}{app.Handle(method,"/storage/object",httpx.WrapHandler(deps.FileHTTP))}
`
}
source += "return app,nil\n}\n"
if o.Storage != "local" {
source = strings.Replace(source, fmt.Sprintf("%q", frameworkModule+"/httpx"), "", 1)
}
return source
}

View File

@@ -0,0 +1,270 @@
package apigen
import "fmt"
var baseTemplates = map[string]string{
"main.go": `package main
import (
"context"
"errors"
"flag"
"log"
"os"
"os/signal"
"syscall"
"git.yuntuo.hk/k2safe/go-omni/logx"
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
"MODULE/internal/config"
"MODULE/internal/handler"
"MODULE/internal/svc"
)
func main(){
path:=flag.String("config","etc/config.yaml","configuration file; empty uses only Default()")
check:=flag.Bool("check-config",false,"validate configuration and exit without starting dependencies")
flag.Parse()
if *check {if _,err:=config.Load(*path);err!=nil{log.Fatal(err)};return}
ctx,stop:=signal.NotifyContext(context.Background(),os.Interrupt,syscall.SIGTERM)
defer stop()
if err:=run(ctx,*path);err!=nil{log.Fatal(err)}
}
func run(ctx context.Context,path string)(runErr error){
cfg,err:=config.Load(path);if err!=nil{return err}
logger,err:=logx.New(cfg.Server.Log);if err!=nil{return err}
// Unjoined HTTP or background work may still log. The owning process exits on this
// error; do not close its borrowed logger before that work has finished.
defer func(){if !errors.Is(runErr,lifecycle.ErrResourcesRetained){runErr=errors.Join(runErr,logger.Close())}}()
deps,err:=svc.New(ctx,logger,cfg);if err!=nil{return err}
// Finish propagates incomplete HTTP draining: stop background work but retain
// shared dependencies when any handler is still running. Otherwise close once.
defer func(){runErr=deps.Finish(runErr)}()
app,err:=handler.NewApp(deps);if err!=nil{return err}
defer func(){runErr=errors.Join(runErr,app.Close())}()
logger.InfoContext(ctx,"service.starting","address",app.Config().Address)
return app.RunContext(deps.Lifecycle.Context())
}
`,
".gitignore": "/server\n/var/\n.env\n.env.*\n!.env.example\n/.goOmni/api.lock\n",
}
func projectREADME(module, entry string) string {
return fmt.Sprintf(`# %s
This project is generated from an editable API contract. Business code uses
standard context.Context; generated HTTP handlers use Omni's transport-neutral httpx.
main.go and HTTP composition are continuously generator-owned.
omni.gen.json selects repository: none (default) or group, and middleware:
factories for new projects. Named middleware factories run after svc.Init;
implement Dependencies accessors on ServiceContext and New<Name>(deps) once.
Unimplemented/nil factories fail startup and clean registered dependencies.
Existing projects retain middleware: handlers until explicitly migrated.
Ordinary Logic uses the generated Request/Response types directly. Where an
independent business type is needed, use ordinary Go or the optional copyx package.
Configuration loading is generator-managed in internal/config/loadGen.go. Extend fields/defaults/Validate in config.go; additional decoders belong in loader.go/NewLoader. JSON/YAML/XML use explicit ${NAME} / ${NAME:-fallback} references; fixed values are never overridden by inferred environment names. Use -check-config to validate without opening dependencies. Framework validation
identifies fields such as server.log.level and database.port. server.log.timeZone
optionally selects a per-logger IANA zone. Optional server.cors is loaded and installed
automatically before routing/authentication; disabled by default. Configure allowedOrigins,
allowedMethods and allowedHeaders in the selected file; no main/handler edits.
See the framework CONFIG.md and cors/README.md.
There are no mappings/mappingFiles settings or generated mapping files in v0.19.
See COPY.md in the framework repository for copy policies and migration steps,
and GENERATION.md for field composition and middleware factories.
## Run
`+"```sh"+`
go mod tidy
go run .
`+"```"+`
GET /health/live and /health/ready check the running service. GET /openapi.json
serves the same document as docs/openapi.json. Bind address defaults to localhost.
Configure server.endpoints to disable or move each built-in route. Health
responseMode is app by default (project format), or raw for plain health data.
Probe HTTP statuses remain 200/503 even when business errors use HTTP 200.
OpenAPI remains an unwrapped document; disabled endpoints reserve no paths.
The default file explicitly references GO_OMNI_TRANSPORT; set it to native or edit server.transport.
The greeting starter works immediately; other new operation stubs return 501 until
their business logic is implemented. Authentication hooks also fail closed with 501.
## Develop
1. Edit %s and its relative imports.
2. Run the same go-omni CLI version as the framework dependency:
`+"```sh"+`
go-omni api validate
go-omni api inspect
go-omni api generate --dry-run
go-omni api generate
go-omni api generate --check
go-omni api export > openapi.json
go test ./...
`+"```"+`
Commands inside a generated project discover the recorded API entry automatically.
--dry-run previews create/update/remove actions without writing files.
The --check command is read-only and suitable for CI. It fails on stale or missing
generated files and missing business scaffolds. go.mod, configuration, Logic,
Repository, svc, middleware hook bodies and API files are created once and then
preserved byte for byte. main.go, Gen.go files and OpenAPI are owned
by the generator; manual edits cause a conflict instead of being overwritten.
Commit .goOmni/apiManifest.json alongside the generated files. Do not edit it.
Avoid editing files while generation is running; api.lock serializes generators.
Routes and types that disappear are removed from generated output. Handwritten
business files remain: remove/adapt obsolete implementations explicitly if an
API change makes their old types or signatures fail to compile. Every regeneration
must be followed by go test ./... . Do not delete service files to change contracts.
Generation compares the last saved contract with the new one and reports affected
Logic files, including nested DTO field/tag changes and streaming/body changes.
REVIEW notices require a business review. ERROR diagnostics identify definite
Logic signature, syntax or duplicate-method problems. Generation preserves
business code and still writes valid generated output; fix those methods next.
--check fails on definite Logic errors even after generated files are current.
Import aliases, parameter names and moved methods within a group are supported.
Project type aliases, embedded methods or build tags may need compiler verification
and produce REVIEW notices. This source scan does not execute project code or
replace go test ./... . Snapshot-based REVIEW notices describe changes since the
last successful generation; they do not persist as an acknowledgement checklist.
## Contract tools
api format [--check] preserves comments and normalizes indentation. api upgrade
previews changed infrastructure; --apply only replaces recognized prior seeds.
Use @request policy overrides and @app(request: {...}) for query/JSON strictness.
Explicitly exported api/omni.errors.json documents the Go-owned error catalog;
the CLI never executes project Go. omni.imports.json permits named shared roots;
workspace format/generate/check processes explicitly listed independent services.
Full reference: https://git.yuntuo.hk/k2safe/go-omni/src/branch/main/DEVELOPMENT.md
## Layout
`+"```text"+`
api/ # editable contract and imported types
main.go # GENERATED startup, lifecycle and route registration
etc/config.yaml # lowerCamelCase configuration
internal/config/ # defaults -> file references -> typed binding -> validation
internal/svc/ # dependency initialization and cleanup
internal/types/*Gen.go # DTOs split by source API, one types package
internal/svc/runtimeGen.go # GENERATED lifecycle and infrastructure startup
internal/apierrors/errorsGen.go # generated fresh error constructors, when declared
internal/handler/<group>/ # generated binding and Logic adapters
internal/logic/<group>/ # developer-owned business operations
internal/repository/<group>/ # optional; repository: group creates editable seeds
internal/middleware/ # typed dependency interface, factories and registry
omni.gen.json # developer-owned repository/middleware options
docs/openapi.json # generated OpenAPI 3.0.3
.goOmni/apiManifest.json # ownership and content hashes
`+"```"+`
The API response type describes business data. Declare response Name (...) and
select the App default with @app(defaultResponse: Name) in the entry API.
@server(response: Name) and @response Name override it for groups/routes;
@response raw returns the business JSON directly. Without a template, the default
envelope includes code, message, data and requestId. Generated routes and OpenAPI
use the same contract. 404/405 and ordinary App routes use the declared App default.
No template is selected by declaration/import order. Without @app, existing framework
response defaults apply. An explicit API App default takes precedence over response
options in svc, keeping generated routes and OpenAPI consistent.
Object responses reject nil; []DTO maps nil to []; *DTO permits null; returns(null) is a successful null value. No returns means 204. Body DTOs use application/json and have a 1 MiB limit by default.
Use @body(maxBytes: ...) for route limits. @body(mode: raw|multipart; maxBytes: ...)
adds a *httpx.Body argument to Logic, without handwritten handlers. Raw is the exact
limited request stream; Parts supports sequential multipart reading with no temp files.
@produces "application/octet-stream" (or another concrete media type) generates a
Logic result of *httpx.Stream; the handler closes Content and streams it unwrapped.
Signature verification and storage policy belong to Logic/middleware. A consumed
request stream cannot be reread automatically; use one reader owner or explicitly
bounded buffering. Stream reads respect configured server timeouts. Declare multipart/multipartJSON/file DTO fields for validated uploads; without those fields the existing streaming Body is passed. form tags remain URL query parameters. The optional manual
handler/custom binder hooks are preserved, and are needed only for custom protocols.
Fields require one source tag: json, path, form (URL query), header, multipart, multipartJSON or file. Multipart fields select full automatic binding before Logic; File/[]File are request-scoped disk-backed uploads. Use File.Open to stream them; generated cleanup owns their lifetime.
All are required by presence unless optional, omitempty or default is specified.
Zero and false are valid present values; binding:"required" rejects scalar zero.
Use *T with optional when business logic needs to distinguish missing from zero.
Pointers may carry null; non-pointer null is rejected. Unknown/duplicate JSON
fields and duplicate scalar query/header values are rejected. Arrays in the query
use repeated keys. Enum/range/default and nested fields share the documentation
and binding compiler. Responses are expected to honor their declared constraints;
they are not validated on every response. Do not return nil slices/maps for a
non-nullable collection (initialize empty collections instead).
Go-owned errors can be registered once through svc.ServiceContext.AppOptions with
omni.WithErrors(projecterrors.Catalog()). Generated routes inherit them unless they
declare an independent catalog. Do not repeat Go-owned errors in the API contract.
Alternatively, declare public business errors in error (...) and reference them with @errors.
Use internal/apierrors constructors to share status, code and message with OpenAPI.
Auth declarations connect a bearer/header/cookie scheme to a real named middleware.
The selected auth runs first; @auth none removes inherited declarative auth.
Validation policy remains in the middleware implementation. Public messages must
not contain secrets; error causes may be included in server logs.
Generated svc.New receives Config and a borrowed Logger. Add dependencies in svc.Init,
then immediately register each cleanup with OnClose. A later initialization failure
runs registered cleanups in reverse order. Close is idempotent. GlobalMiddleware
provides global hooks; Middlewares supplies named API hooks. AppOptions is evaluated
once before route registration; logging is configured earlier through Config.
The generated main runs HTTP draining, all OnStop hooks, all OnDrain hooks, resource
OnClose callbacks, then logger close. Stop/drain hooks within a phase run concurrently;
combine components with ordering dependencies into one pair. Pair OnStop/OnDrain
with the same name. OnDrain returns nil only after final work and acknowledgements
have finished. Use WorkContext for accepted background work; Context is an early
shutdown notification. server.lifecycle configures stopTimeout (5s), drainTimeout
(10s), and cancelTimeout (5s grace per timed-out phase). Zero uses defaults.
Timeout/failure cancels WorkContext. If hooks cannot prove completion, resources
and the borrowed logger are retained and the generated process exits unsuccessfully.
Errors retain their causes. No automatic retry of close or force killing of goroutines.
Do not close borrowed resources or attach duplicate cleanup hooks to App. The startup
context cancels on signals; long-lived clients/workers that requests depend on need
their own lifetime, canceled by OnClose after HTTP draining.
svc.RequestShutdown(err) lets a dependency report a fatal service failure.
It returns immediately. Generated main stops accepting requests, drains them,
closes dependencies and logs, then exits nonzero with the original cause.
nil requests a normal stop. Concurrent calls keep the first non-nil failure;
notifications during initialization or draining are retained. Close must stop
and join project-owned monitors before the final shutdown result is returned.
The svc lifecycle context signals draining, not permission to close resources
still used by active requests. Liveness and readiness are different: shutdown
makes readiness 503; it does not turn a running process's liveness into failure.
Named middleware is composed in declaration order (first is outermost). Implement
the generated hooks or replace entries in svc; never bypass an auth
hook merely to make the server start. Middleware names do not imply a JWT policy.
This CLI supports a documented go-zero-style subset, not all goctl features.
Database tables and repositories cannot be inferred from an HTTP contract. Add
business repositories through svc. Database and storage options are
composed at creation; the original profile is retained on regeneration.
`, module, entry)
}
func infrastructureREADME(o Options) string {
return fmt.Sprintf(`
## Infrastructure
Selected database: %s; storage: %s; logging: %s/%s.
MySQL uses database/sql through deps.DB, including InTx and a readiness probe.
Use structured database/mysql configuration. The generated etc/config.yaml explicitly
references GO_OMNI_DB_PASSWORD and other required connection values; add fixed
values or explicit references for pool/TLS fields. No implicit environment overlay.
No tables are invented or migrated automatically from request/response DTOs.
The none/memory selection opens no database; implement repositories per business.
With storage enabled, services receive deps.Files, Uploads, Downloads and
UploadLinks capability interfaces. Local signed GET/HEAD/PUT uses /storage/object;
the generated file references GO_OMNI_STORAGE_SIGNING_KEY (32+ bytes) and
GO_OMNI_STORAGE_PUBLIC_URL. File values may instead be fixed explicitly.
Cloud credentials use GO_OMNI_STORAGE_ACCESS_KEY/SECRET_KEY; set bucket and endpoint
or R2 accountId as appropriate. Signed URLs grant temporary access: authenticate
and authorize before issuing them. No public upload-link route is added.
Storage failures during startup close an already-open database; Close is idempotent.
Database/storage/logging selection is recorded in .goOmni/apiManifest.json.
Changing selection through generation flags is rejected on an existing project;
edit your owned configuration and initialization explicitly, or generate a fresh
project for comparison. New routes still regenerate without repeating these flags.
`, o.Database, o.Storage, o.LogOutput, o.LogFormat)
}

View File

@@ -0,0 +1,54 @@
package main
import (
"context"
"encoding/json"
"example.com/arrays/internal/config"
"example.com/arrays/internal/handler"
"example.com/arrays/internal/svc"
"git.yuntuo.hk/k2safe/go-omni/logx"
"io"
"net/http/httptest"
"strings"
"testing"
)
func TestArrayWire(t *testing.T) {
cfg := config.Default()
cfg.Server.Log.Writer = io.Discard
logger, err := logx.New(cfg.Server.Log)
if err != nil {
t.Fatal(err)
}
defer logger.Close()
deps, err := svc.New(context.Background(), logger, cfg)
if err != nil {
t.Fatal(err)
}
defer deps.Close()
app, err := handler.NewApp(deps)
if err != nil {
t.Fatal(err)
}
defer app.Close()
for path, want := range map[string]string{
"/strings": `["企业","备注"]`, "/integers": `[9223372036854775807,-3]`, "/dynamic": `["text",7,true,null,{"name":"企业"},[false,2]]`, "/empty": `[]`, "/raw": `[true,false]`,
} {
w := httptest.NewRecorder()
app.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
got := strings.TrimSpace(w.Body.String())
if w.Code != 200 {
t.Fatal(got)
}
if path != "/raw" {
var obj map[string]json.RawMessage
if err := json.Unmarshal(w.Body.Bytes(), &obj); err != nil {
t.Fatal(err)
}
got = string(obj["data"])
}
if got != want {
t.Fatalf("%s got %s want %s", path, got, want)
}
}
}

View File

@@ -0,0 +1,13 @@
package arrays
import "context"
func (s *Logic) Strings(context.Context) ([]string, error) { return []string{"企业", "备注"}, nil }
func (s *Logic) Integers(context.Context) ([]int64, error) {
return []int64{9223372036854775807, -3}, nil
}
func (s *Logic) Dynamic(context.Context) ([]any, error) {
return []any{"text", 7, true, nil, map[string]any{"name": "企业"}, []any{false, 2}}, nil
}
func (s *Logic) Empty(context.Context) ([]any, error) { return nil, nil }
func (s *Logic) Raw(context.Context) ([]bool, error) { return []bool{true, false}, nil }

View File

@@ -0,0 +1,22 @@
package middleware
import (
"context"
"git.yuntuo.hk/k2safe/go-omni/httpx"
)
// This package deliberately imports no svc; the dependency is supplied explicitly.
type AccessChecker interface {
CheckAccess(context.Context, string) error
}
func NewRequireAccess(checker AccessChecker) httpx.Middleware {
return func(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error {
if err := checker.CheckAccess(c.Context(), c.Header("Authorization")); err != nil {
return err
}
return next(c)
}
}
}

View File

@@ -0,0 +1,32 @@
package svc
import (
"context"
"example.com/business/internal/middleware"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"sync/atomic"
)
// A local fixture checks wiring; production authentication is project-owned.
type accessClient struct{ closed atomic.Bool }
func (c *accessClient) Close() error { c.closed.Store(true); return nil }
func (c *accessClient) CheckAccess(ctx context.Context, token string) error {
if c.closed.Load() {
return apierrors.New(503, "CLOSED", "closed")
}
if httpx.Metadata(ctx).Route == "" {
return apierrors.New(500, "NO_METADATA", "missing route")
}
if token == "reject" {
return apierrors.New(403, "DENIED", "denied")
}
return nil
}
func (c *ServiceContext) initAccess(ctx context.Context) error {
c.Client = &accessClient{}
c.OnClose(c.Client.Close)
c.Middlewares["RequireAccess"] = middleware.NewRequireAccess(c.Client)
return nil
}

Some files were not shown because too many files have changed in this diff Show More