feat: release managed service generation and contract diagnostics

This commit is contained in:
2026-09-24 17:34:21 +08:00
parent eb69260586
commit 158b8002b3
85 changed files with 5722 additions and 360 deletions

8
examples/managedService/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
/server
/var/
.env
.env.*
!.env.example
/.goOmni/api.lock
/managed

View File

@@ -0,0 +1,161 @@
{
"profile": {
"database": "none",
"storage": "none",
"logOutput": "stderr",
"logFormat": "json"
},
"version": 2,
"module": "example.com/managed",
"entry": "api/managed.api",
"generated": {
"docs/openapi.json": "9eb707aefeefd3f4881c774534e2d143949c0edb1d8a4aabd762f978f6203df1",
"internal/handler/appGen.go": "dbadfb73165e0e6ef10e8277dd1b8a0edc2992dac3690d634808ac92829c2654",
"internal/handler/demo/handlersGen.go": "7a6f9d5e105e44b07196cc540cee3f0562d5f3d1c5f0bc094434db1064d0339c",
"internal/handler/openapiGen.go": "612920ed25e5dc7a4f673be56041d47b70aa3216707ff282e52dfe503c2d705a",
"internal/handler/responseGen.go": "84397a502bba4777ebfa6fae3d3bc418297acab4f26b04b42e3c31b36c735399",
"internal/handler/routesGen.go": "76c1a5b2644e75e56553dc6e9e862a63128d902dd94807ce7c8aee5491cd1e3e",
"internal/middleware/registryGen.go": "e5f47efa57a1432765ef3419bae59dbf3b4d55b3aac0a6c935ce7a52ba6b47f4",
"internal/types/typesGen.go": "7586e86fa5d67051283178cc578c890f6c02fd2d5c383170a71a39c2e77ce344",
"main.go": "09ae81857b806f3f7dca608399b1881d94bc9c4b2258e774e77d694fcadbab65"
},
"contract": {
"routes": {
"demo/callback": {
"method": "POST",
"path": "/callback",
"group": "demo",
"handler": "callback",
"signature": "Callback(context.Context, *httpx.Body) (*httpx.Stream, error)",
"bodyMode": "raw",
"produces": "text/plain",
"maxBodyBytes": 64
},
"demo/check": {
"method": "GET",
"path": "/check",
"group": "demo",
"handler": "check",
"signature": "Check(context.Context, *types.CheckReq) (*types.Receipt, error)",
"request": "CheckReq",
"response": "Receipt",
"types": {
"CheckReq": {
"fields": [
{
"name": "Kind",
"type": "string",
"tag": "form:\"kind,optional\""
}
]
},
"Receipt": {
"fields": [
{
"name": "Bytes",
"type": "int64",
"tag": "json:\"bytes\""
}
]
}
}
},
"demo/download": {
"method": "GET",
"path": "/download",
"group": "demo",
"handler": "download",
"signature": "Download(context.Context) (*httpx.Stream, error)",
"produces": "application/octet-stream"
},
"demo/other": {
"method": "GET",
"path": "/other",
"group": "demo",
"handler": "other",
"signature": "Other(context.Context, *types.CheckReq) (*types.Receipt, error)",
"request": "CheckReq",
"response": "Receipt",
"types": {
"CheckReq": {
"fields": [
{
"name": "Kind",
"type": "string",
"tag": "form:\"kind,optional\""
}
]
},
"Receipt": {
"fields": [
{
"name": "Bytes",
"type": "int64",
"tag": "json:\"bytes\""
}
]
}
}
},
"demo/rawJSON": {
"method": "GET",
"path": "/raw",
"group": "demo",
"handler": "rawJSON",
"signature": "RawJSON(context.Context) (*types.Receipt, error)",
"response": "Receipt",
"types": {
"Receipt": {
"fields": [
{
"name": "Bytes",
"type": "int64",
"tag": "json:\"bytes\""
}
]
}
}
},
"demo/slow": {
"method": "GET",
"path": "/slow",
"group": "demo",
"handler": "slow",
"signature": "Slow(context.Context) (*types.Receipt, error)",
"response": "Receipt",
"types": {
"Receipt": {
"fields": [
{
"name": "Bytes",
"type": "int64",
"tag": "json:\"bytes\""
}
]
}
}
},
"demo/upload": {
"method": "POST",
"path": "/upload",
"group": "demo",
"handler": "upload",
"signature": "Upload(context.Context, *httpx.Body) (*types.Receipt, error)",
"response": "Receipt",
"bodyMode": "multipart",
"maxBodyBytes": 2048,
"types": {
"Receipt": {
"fields": [
{
"name": "Bytes",
"type": "int64",
"tag": "json:\"bytes\""
}
]
}
}
}
}
}
}

View File

@@ -0,0 +1,175 @@
# example.com/managed
This checked-in example uses a relative local framework replacement (`../..`).
Run `go mod tidy && go run .` here. No external database or cloud account is required.
The Go-only error catalog is `internal/apierrors/catalog.go`, registered by svc.
The generated main and handlers are unmodified. Try:
```sh
curl -i 'http://127.0.0.1:8080/check?kind=fixed'
curl -i 'http://127.0.0.1:8080/check?kind=params'
curl -i 'http://127.0.0.1:8080/missing'
curl -i -H 'X-Signature: valid' --data-binary ' {"hello":1} ' http://127.0.0.1:8080/callback
curl -i -F 'file=hello' http://127.0.0.1:8080/upload
curl -OJ http://127.0.0.1:8080/download
```
The upload demonstration caps requests at 2048 bytes, so use a small test file;
large input is intentionally rejected with HTTP 413. Replace the demonstration
`X-Signature` check with the actual provider's signature algorithm and secret.
From the framework root, check generated files with:
`go run ./cmd/go-omni api generate --dir examples/managedService --check`.
Contract changes produce REVIEW notices for affected Logic methods and DTO fields.
Definite Logic signature problems produce ERROR diagnostics with a file/line and
the required signature. Regeneration preserves business code; `--check` continues
to fail on unresolved signature errors after generation. Run `go test ./...` too,
especially for project type aliases, build tags and changes inside method bodies.
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.
## 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.
Set GO_OMNI_TRANSPORT=native to select the native transport without changing services.
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 api/managed.api 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.
## 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 -> environment
internal/svc/ # dependency initialization and cleanup
internal/types/typesGen.go # generated request/response types
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>/ # developer-owned persistence contracts
internal/middleware/ # named hooks and generated registry
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.
Handlers reject nil successful responses. 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. Multipart parts
are not DTO form fields: form tags remain URL query parameters. The optional manual
handler/custom binder hooks are preserved, and are needed only for custom protocols.
Fields require exactly one source tag: json, path, form (URL query only), header.
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.
svc.New receives the loaded Config and borrowed Logger. Add dependencies there,
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 before closing svc resources, then the logger.
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.
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.
## Infrastructure
Selected database: none; storage: none; logging: stderr/json.
MySQL uses database/sql through deps.DB, including InTx and a readiness probe.
Set GO_OMNI_DB_DSN; pool settings live in etc/config.yaml and GO_OMNI_DB_*.
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;
set GO_OMNI_STORAGE_SIGNING_KEY (32+ bytes) and GO_OMNI_STORAGE_PUBLIC_URL.
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.

View File

@@ -0,0 +1,57 @@
syntax = "v1"
// Error definitions are intentionally owned by Go, not duplicated here.
@app (
defaultResponse: CommonResponse
)
response CommonResponse (
codeType: int32
successCode: 0
errorCode: 9999
messageField: msg
dataMode: always
requestIdField: "-"
)
response AlternateResponse (
codeType: int32
successCode: 0
errorCode: 9999
messageField: detail
dataMode: always
requestIdField: "-"
)
type CheckReq {
Kind string `form:"kind,optional"`
}
type Receipt {
Bytes int64 `json:"bytes"`
}
@server (group: demo)
service managed-api {
@handler check
get /check (CheckReq) returns (Receipt)
@handler other
@response AlternateResponse
get /other (CheckReq) returns (Receipt)
@handler rawJSON
@response raw
get /raw returns (Receipt)
@handler upload
@body (mode: multipart; maxBytes: 2048)
post /upload returns (Receipt)
@handler callback
@body (mode: raw; maxBytes: 64)
@produces "text/plain"
post /callback
@handler download
@produces "application/octet-stream"
get /download
@handler slow
get /slow returns (Receipt)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,12 @@
# Non-secret settings only. Environment variables override these values.
server:
address: '127.0.0.1:8080'
enableLogger: true
slowRequestThreshold: 500ms
shutdownTimeout: 10s
log:
service: "example.com/managed"
level: info
format: json
output: stderr
redactKeys: []

View File

@@ -0,0 +1,22 @@
module example.com/managed
go 1.22.0
require git.yuntuo.hk/k2safe/go-omni v0.12.0-preview
require (
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace git.yuntuo.hk/k2safe/go-omni => ../..

View File

@@ -0,0 +1,34 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,18 @@
package apierrors
import omnierrors "git.yuntuo.hk/k2safe/go-omni/errors"
func Catalog() omnierrors.Catalog {
return omnierrors.Catalog{
DefaultStatus: 400,
Definitions: map[string]omnierrors.Definition{
"INVALID": {Code: 400002, Message: "用户名格式不正确"},
"TOO_LONG": {Code: 400003, Message: "{label}不能超过{max}个字符", Params: []omnierrors.Parameter{{Name: "label", Type: "string"}, {Name: "max", Type: "int"}}},
"INTERNAL_ERROR": {Status: 500, Code: 9999, Message: "系统错误"},
"NOT_FOUND": {Status: 404, Code: 404001, Message: "接口不存在"},
"METHOD_NOT_ALLOWED": {Status: 405, Code: 405001, Message: "请求方法不支持"},
"BODY_TOO_LARGE": {Status: 413, Code: 413001, Message: "请求体太大"},
"INVALID_MULTIPART": {Status: 400, Code: 400004, Message: "上传内容不完整"},
},
}
}

View File

@@ -0,0 +1,69 @@
// Package settings owns this project's configuration; it adds no business identity model.
package config
import (
"errors"
"os"
"strings"
"time"
"git.yuntuo.hk/k2safe/go-omni/config"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
type Config struct {
Server config.Config `json:"server"`
}
func Default() Config {
server := config.Default()
server.Address = "127.0.0.1:8080"
server.EnableLogger = true
server.SlowRequestThreshold = 500 * time.Millisecond
server.Log.Service = "example.com/managed"
server.Log.Format = "json"
server.Log.Output = "stderr"
return Config{Server: server}
}
// Load uses defaults -> strict YAML/JSON -> nonempty environment variables.
// Pass an empty path to use only defaults and environment.
func Load(path string) (Config, error) {
cfg := Default()
var err error
if path != "" {
cfg, err = config.DecodeFile(path, cfg)
if err != nil {
return Config{}, err
}
}
cfg.Server, err = config.ApplyEnv(cfg.Server, "")
if err != nil {
return Config{}, err
}
// A stream explicitly selected by environment overrides a file profile.
if output := strings.TrimSpace(os.Getenv("GO_OMNI_LOG_OUTPUT")); output != "" && strings.TrimSpace(os.Getenv("GO_OMNI_LOG_FILE")) == "" {
cfg.Server.Log.File = ""
cfg.Server.Log.Rotation = logx.RotationConfig{}
}
if err = cfg.Validate(); err != nil {
return Config{}, err
}
return cfg, nil
}
func (c Config) Validate() error {
if err := c.Server.Validate(); err != nil {
return err
}
if c.Server.Log.Format != "json" && c.Server.Log.Format != "text" {
return errors.New("log.format must be json or text")
}
switch strings.ToLower(c.Server.Log.Level) {
case "debug", "info", "warn", "warning", "error":
default:
return errors.New("invalid log.level")
}
return nil
}

View File

@@ -0,0 +1,36 @@
// Code generated by go-omni api. DO NOT EDIT.
package handler
import (
"fmt"
omni "git.yuntuo.hk/k2safe/go-omni"
"example.com/managed/internal/svc"
)
// NewApp is generated HTTP composition. The caller owns deps and its logger.
func NewApp(deps *svc.ServiceContext) (*omni.App, error) {
if deps == nil || deps.Health == nil {
return nil, fmt.Errorf("service context and health checker required")
}
cfg := deps.Config.Server
for _, option := range deps.AppOptions() {
if option != nil {
option(&cfg)
}
}
cfg.Response = newResponseTemplate("{\"body\":null,\"errorBody\":null,\"messageField\":\"msg\",\"requestIdField\":\"-\",\"codeType\":\"int32\",\"successCode\":0,\"errorCode\":9999,\"dataMode\":\"always\"}").Config()
cfg.ResponseEncoder = nil
if err := cfg.Validate(); err != nil {
return nil, err
}
app := omni.New(omni.WithConfig(cfg))
app.Use(deps.GlobalMiddleware()...)
if err := RegisterRoutes(app, deps); err != nil {
return nil, err
}
app.GET("/openapi.json", OpenAPI)
app.GET("/health/live", deps.Health.LivenessHandler())
app.GET("/health/ready", deps.Health.ReadinessHandler())
return app, nil
}

View File

@@ -0,0 +1,134 @@
// Code generated by go-omni api. DO NOT EDIT.
package demo
import (
"context"
"example.com/managed/internal/svc"
"example.com/managed/internal/types"
"fmt"
"git.yuntuo.hk/k2safe/go-omni/contract"
"git.yuntuo.hk/k2safe/go-omni/httpx"
)
type Logic interface {
Check(ctx context.Context, req *types.CheckReq) (*types.Receipt, error)
Other(ctx context.Context, req *types.CheckReq) (*types.Receipt, error)
RawJSON(ctx context.Context) (*types.Receipt, error)
Upload(ctx context.Context, body *httpx.Body) (*types.Receipt, error)
Callback(ctx context.Context, body *httpx.Body) (*httpx.Stream, error)
Download(ctx context.Context) (*httpx.Stream, error)
Slow(ctx context.Context) (*types.Receipt, error)
}
type Handler struct {
logic Logic
deps *svc.ServiceContext
}
func New(s Logic, deps ...*svc.ServiceContext) *Handler {
h := &Handler{logic: s}
if len(deps) > 0 {
h.deps = deps[0]
}
return h
}
var checkRequest = contract.MustNew[types.CheckReq]()
func (h *Handler) Check(c *httpx.Context) error {
var req types.CheckReq
if err := checkRequest.Bind(c.Request(), httpx.RouteParams(c.Context()), &req); err != nil {
return err
}
resp, err := h.logic.Check(c.Context(), &req)
if err != nil {
return err
}
if resp == nil {
return fmt.Errorf("Check returned a nil response")
}
return c.OK(resp)
}
var otherRequest = contract.MustNew[types.CheckReq]()
func (h *Handler) Other(c *httpx.Context) error {
var req types.CheckReq
if err := otherRequest.Bind(c.Request(), httpx.RouteParams(c.Context()), &req); err != nil {
return err
}
resp, err := h.logic.Other(c.Context(), &req)
if err != nil {
return err
}
if resp == nil {
return fmt.Errorf("Other returned a nil response")
}
return c.OK(resp)
}
func (h *Handler) RawJSON(c *httpx.Context) error {
resp, err := h.logic.RawJSON(c.Context())
if err != nil {
return err
}
if resp == nil {
return fmt.Errorf("RawJSON returned a nil response")
}
return c.OK(resp)
}
func (h *Handler) Upload(c *httpx.Context) error {
body, err := httpx.NewBody(c.Request(), "multipart")
if err != nil {
return err
}
resp, err := h.logic.Upload(c.Context(), body)
if err != nil {
return err
}
if resp == nil {
return fmt.Errorf("Upload returned a nil response")
}
if err := body.Complete(); err != nil {
return err
}
return c.OK(resp)
}
func (h *Handler) Callback(c *httpx.Context) error {
body, err := httpx.NewBody(c.Request(), "raw")
if err != nil {
return err
}
resp, err := h.logic.Callback(c.Context(), body)
if err != nil {
if resp != nil {
_ = resp.Close()
}
return err
}
if err := body.Complete(); err != nil {
if resp != nil {
_ = resp.Close()
}
return err
}
return c.WriteStream(200, "text/plain", resp)
}
func (h *Handler) Download(c *httpx.Context) error {
resp, err := h.logic.Download(c.Context())
if err != nil {
if resp != nil {
_ = resp.Close()
}
return err
}
return c.WriteStream(200, "application/octet-stream", resp)
}
func (h *Handler) Slow(c *httpx.Context) error {
resp, err := h.logic.Slow(c.Context())
if err != nil {
return err
}
if resp == nil {
return fmt.Errorf("Slow returned a nil response")
}
return c.OK(resp)
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,41 @@
// Code generated by go-omni api. DO NOT EDIT.
package handler
import (
"encoding/json"
omnierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/response"
"strings"
)
var responseTemplates = map[string]response.Encoder{"": response.Default(),
"CommonResponse": newResponseTemplate("{\"body\":null,\"errorBody\":null,\"messageField\":\"msg\",\"requestIdField\":\"-\",\"codeType\":\"int32\",\"successCode\":0,\"errorCode\":9999,\"dataMode\":\"always\"}"),
"AlternateResponse": newResponseTemplate("{\"body\":null,\"errorBody\":null,\"messageField\":\"detail\",\"requestIdField\":\"-\",\"codeType\":\"int32\",\"successCode\":0,\"errorCode\":9999,\"dataMode\":\"always\"}"),
}
func 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)
}
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
}

View File

@@ -0,0 +1,26 @@
// Code generated by go-omni api. DO NOT EDIT.
package handler
import (
h0 "example.com/managed/internal/handler/demo"
s0 "example.com/managed/internal/logic/demo"
"example.com/managed/internal/svc"
"fmt"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/openapi"
)
func RegisterRoutes(router openapi.Registrar, deps *svc.ServiceContext) error {
if router == nil || deps == nil {
return fmt.Errorf("router and dependencies required")
}
h0 := h0.New(s0.New(deps), deps)
httpx.HandleWithResponse(router, "GET", "/check", responseEncoder("CommonResponse", false, "{\"definitions\":{}}"), h0.Check)
httpx.HandleWithResponse(router, "GET", "/other", responseEncoder("AlternateResponse", false, "{\"definitions\":{}}"), h0.Other)
httpx.HandleWithResponse(router, "GET", "/raw", responseEncoder("CommonResponse", true, "{\"definitions\":{}}"), h0.RawJSON)
httpx.HandleWithBodyLimit(router, "POST", "/upload", 2048, responseEncoder("CommonResponse", false, "{\"definitions\":{}}"), h0.Upload)
httpx.HandleWithBodyLimit(router, "POST", "/callback", 64, responseEncoder("CommonResponse", false, "{\"definitions\":{}}"), h0.Callback)
httpx.HandleWithResponse(router, "GET", "/download", responseEncoder("CommonResponse", false, "{\"definitions\":{}}"), h0.Download)
httpx.HandleWithResponse(router, "GET", "/slow", responseEncoder("CommonResponse", false, "{\"definitions\":{}}"), h0.Slow)
return nil
}

View File

@@ -0,0 +1,22 @@
package demo
import (
"context"
omnierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"io"
"strings"
)
func (l *Logic) Callback(ctx context.Context, body *httpx.Body) (*httpx.Stream, error) {
// The route's 64-byte limit bounds this example's memory use. A real signature
// implementation can hash body.Raw incrementally and authenticate its headers.
data, err := io.ReadAll(body.Raw)
if err != nil {
return nil, err
}
if body.Header.Get("X-Signature") != "valid" {
return nil, omnierrors.ByCode("INVALID")
}
return &httpx.Stream{Content: io.NopCloser(strings.NewReader("ACK:" + string(data)))}, nil
}

View File

@@ -0,0 +1,20 @@
package demo
import (
"context"
"errors"
"example.com/managed/internal/types"
omnierrors "git.yuntuo.hk/k2safe/go-omni/errors"
)
func (l *Logic) Check(ctx context.Context, req *types.CheckReq) (*types.Receipt, error) {
switch req.Kind {
case "fixed":
return nil, omnierrors.ByCode("INVALID")
case "params":
return nil, omnierrors.ByCode("TOO_LONG").WithParams(map[string]any{"label": "企业名称", "max": 100})
case "unknown":
return nil, omnierrors.ByCode("UNKNOWN").WithCause(errors.New("private cause"))
}
return &types.Receipt{Bytes: 42}, nil
}

View File

@@ -0,0 +1,12 @@
package demo
import (
"context"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"io"
"strings"
)
func (l *Logic) Download(context.Context) (*httpx.Stream, error) {
return &httpx.Stream{Content: io.NopCloser(strings.NewReader("file-content")), Name: "example.txt"}, nil
}

View File

@@ -0,0 +1,7 @@
package demo
import "example.com/managed/internal/svc"
type Logic struct{ deps *svc.ServiceContext }
func New(deps *svc.ServiceContext) *Logic { return &Logic{deps: deps} }

View File

@@ -0,0 +1,10 @@
package demo
import (
"context"
"example.com/managed/internal/types"
)
func (l *Logic) Other(ctx context.Context, req *types.CheckReq) (*types.Receipt, error) {
return l.Check(ctx, req)
}

View File

@@ -0,0 +1,10 @@
package demo
import (
"context"
"example.com/managed/internal/types"
)
func (l *Logic) RawJSON(context.Context) (*types.Receipt, error) {
return &types.Receipt{Bytes: 3}, nil
}

View File

@@ -0,0 +1,16 @@
package demo
import (
"context"
"example.com/managed/internal/types"
"time"
)
func (l *Logic) Slow(ctx context.Context) (*types.Receipt, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(100 * time.Millisecond):
return &types.Receipt{Bytes: 1}, nil
}
}

View File

@@ -0,0 +1,31 @@
package demo
import (
"context"
"example.com/managed/internal/types"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"io"
)
func (l *Logic) Upload(ctx context.Context, body *httpx.Body) (*types.Receipt, error) {
var total int64
for {
part, err := body.Parts.NextPart()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
n, err := io.Copy(io.Discard, part)
closeErr := part.Close()
if err != nil {
return nil, err
}
if closeErr != nil {
return nil, closeErr
}
total += n
}
return &types.Receipt{Bytes: total}, nil
}

View File

@@ -0,0 +1,8 @@
// Code generated by go-omni api. DO NOT EDIT.
package middleware
import "git.yuntuo.hk/k2safe/go-omni/httpx"
func Defaults() map[string]httpx.Middleware {
return map[string]httpx.Middleware{}
}

View File

@@ -0,0 +1,4 @@
// Package demo owns persistence contracts for this business group.
// Define repository interfaces and implementations here; HTTP DTOs do not
// determine a database schema. This file is never overwritten.
package demo

View File

@@ -0,0 +1,36 @@
package svc
import "errors"
// Close is idempotent, including startup failure and graceful shutdown.
func (c *ServiceContext) Close() error {
if c == nil {
return nil
}
c.closeOnce.Do(func() {
c.closeMu.Lock()
c.closed = true
cleanups := c.cleanups
c.cleanups = nil
c.closeMu.Unlock()
for i := len(cleanups) - 1; i >= 0; i-- {
c.closeErr = errors.Join(c.closeErr, cleanups[i]())
}
})
return c.closeErr
}
// OnClose transfers a resource cleanup to this context immediately after creation.
// Hooks run once in reverse order, also when later initialization fails.
func (c *ServiceContext) OnClose(cleanup func() error) {
if cleanup == nil {
return
}
c.closeMu.Lock()
defer c.closeMu.Unlock()
if c.closed {
panic("svc: register cleanup after Close")
}
c.cleanups = append(c.cleanups, cleanup)
}

View File

@@ -0,0 +1,24 @@
package svc
import (
"example.com/managed/internal/apierrors"
omni "git.yuntuo.hk/k2safe/go-omni"
omnierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
)
func (c *ServiceContext) AppOptions() []omni.Option {
return []omni.Option{omni.WithErrors(apierrors.Catalog())}
}
func (c *ServiceContext) GlobalMiddleware() []httpx.Middleware {
return []httpx.Middleware{
func(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error {
if c.Header("X-Fail") != "" {
return omnierrors.ByCode("INVALID")
}
return next(c)
}
},
}
}

View File

@@ -0,0 +1,59 @@
package svc
import (
"context"
"errors"
"sync"
"example.com/managed/internal/config"
"example.com/managed/internal/middleware"
"git.yuntuo.hk/k2safe/go-omni/health"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
// Context owns infrastructure, while Logger is borrowed from main.
// Add business repositories here; HTTP contracts do not define database tables.
type ServiceContext struct {
Config config.Config
cleanups []func() error
Logger *logx.Logger
Health *health.Checker
Middlewares map[string]httpx.Middleware
closeMu sync.Mutex
closed bool
closeOnce sync.Once
closeErr error
}
// Pass the settings loaded by main. The optional form supports lightweight
// tests without infrastructure; selected adapters still require valid config.
func New(ctx context.Context, logger *logx.Logger, configs ...config.Config) (result *ServiceContext, err error) {
if ctx == nil {
ctx = context.Background()
}
if err = ctx.Err(); err != nil {
return nil, err
}
if len(configs) > 1 {
return nil, errors.New("expected at most one configuration")
}
cfg := config.Default()
if len(configs) == 1 {
cfg = configs[0]
}
_ = cfg
checker, err := health.New()
if err != nil {
return nil, err
}
c := &ServiceContext{Config: cfg, Logger: logger, Health: checker, Middlewares: middleware.Defaults()}
defer func() {
if err != nil {
err = errors.Join(err, c.Close())
}
}()
return c, nil
}

View File

@@ -0,0 +1,9 @@
// Code generated by go-omni api. DO NOT EDIT.
package types
type CheckReq struct {
Kind string `form:"kind,optional"`
}
type Receipt struct {
Bytes int64 `json:"bytes"`
}

View File

@@ -0,0 +1,52 @@
// Code generated by go-omni api. DO NOT EDIT.
package main
import (
"context"
"errors"
"example.com/managed/internal/config"
"example.com/managed/internal/handler"
"example.com/managed/internal/svc"
"flag"
"git.yuntuo.hk/k2safe/go-omni/logx"
"log"
"os"
"os/signal"
"syscall"
)
func main() {
path := flag.String("config", "etc/config.yaml", "configuration file; empty uses defaults and environment")
flag.Parse()
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
}
defer func() { runErr = errors.Join(runErr, logger.Close()) }()
logx.SetDefault(logger)
deps, err := svc.New(ctx, logger, cfg)
if err != nil {
return err
}
// RunContext drains HTTP requests before returning. This scope owns dependencies
// and closes them exactly once, before closing the borrowed logger.
defer func() { runErr = errors.Join(runErr, deps.Close()) }()
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(ctx)
}