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.
This commit is contained in:
2026-09-23 20:42:38 +08:00
parent bef7ea2641
commit 7670b222ac
47 changed files with 2689 additions and 120 deletions

View File

@@ -124,7 +124,7 @@ func runAPI(args []string) error {
fmt.Println(string(compiled.Document))
case "inspect":
writer := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(writer, "METHOD\tPATH\tHANDLER\tAUTH\tERRORS")
fmt.Fprintln(writer, "METHOD\tPATH\tHANDLER\tAUTH\tRESPONSE\tERRORS")
definitions := map[string]apigen.ErrorDefinition{}
for _, e := range spec.Errors {
definitions[e.Name] = e
@@ -139,7 +139,11 @@ func runAPI(args []string) error {
e := definitions[name]
errs = append(errs, fmt.Sprintf("%d:%s", e.Status, e.Code))
}
fmt.Fprintf(writer, "%s\t%s\t%s/%s\t%s\t%s\n", strings.ToUpper(r.Method), r.Path, r.Group, r.Handler, auth, strings.Join(errs, ", "))
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()
}

View File

@@ -245,11 +245,20 @@ func Compile(spec *Spec) (*Compiled, error) {
if err != nil {
return nil, err
}
response = openapi.Envelope(s)
response.Required = append(response.Required, "data")
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 := &openapi.Schema{Type: "object", Required: []string{"code", "message"}, Properties: map[string]*openapi.Schema{"code": {Type: "string"}, "message": {Type: "string"}, "requestId": {Type: "string"}}}
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}
}

View File

@@ -1,11 +1,15 @@
package apigen
import (
"encoding/json"
"fmt"
"net/http"
"regexp"
"sort"
"strings"
"git.yuntuo.hk/k2safe/go-omni/openapi"
"git.yuntuo.hk/k2safe/go-omni/response"
)
var errorCode = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`)
@@ -16,6 +20,9 @@ var credentialName = regexp.MustCompile("^[!#$%&'*+.^_`|~0-9A-Za-z-]+$")
func prepareMetadata(original *Spec) (*Spec, error) {
spec := *original
spec.Routes = append([]Route(nil), original.Routes...)
if err := validateResponses(&spec); err != nil {
return nil, err
}
definitions := map[string]ErrorDefinition{}
codes, symbols := map[string]bool{}, map[string]bool{}
for _, e := range spec.Errors {
@@ -124,6 +131,8 @@ func decorateDocument(doc map[string]any, spec *Spec) {
doc["x-omni-errors"] = spec.Errors
}
for _, r := range spec.Routes {
policy := response.MustNew(responseConfig(spec, r))
cfg := policy.Config()
parts := strings.Split(r.Path, "/")
for i, part := range parts {
if strings.HasPrefix(part, ":") {
@@ -131,6 +140,9 @@ func decorateDocument(doc map[string]any, spec *Spec) {
}
}
op := doc["paths"].(map[string]any)[strings.Join(parts, "/")].(map[string]any)[r.Method].(map[string]any)
if r.ResponseFormat != "" {
op["x-omni-response"] = r.ResponseFormat
}
if len(r.Middleware) > 0 {
op["x-omni-middleware"] = r.Middleware
}
@@ -152,7 +164,7 @@ func decorateDocument(doc map[string]any, spec *Spec) {
}
responses := op["responses"].(map[string]any)
if r.Auth != "" && r.Auth != "none" {
responses["401"] = genericErrorResponse("Authentication required")
responses["401"] = genericErrorResponse("Authentication required", cfg)
}
grouped := map[int][]ErrorDefinition{}
for _, name := range r.Errors {
@@ -166,32 +178,43 @@ func decorateDocument(doc map[string]any, spec *Spec) {
key := fmt.Sprint(status)
response, exists := responses[key].(map[string]any)
if !exists {
response = genericErrorResponse(http.StatusText(status))
response = genericErrorResponse(http.StatusText(status), cfg)
responses[key] = response
}
media := response["content"].(map[string]any)["application/json"].(map[string]any)
examples := map[string]any{}
codes := []any{}
seenCodes := map[string]bool{}
description := []string{}
sort.Slice(errs, func(i, j int) bool { return errs[i].Name < errs[j].Name })
for _, e := range errs {
examples[e.Name] = map[string]any{"summary": e.Message, "value": map[string]any{"code": e.Code, "message": e.Message, "requestId": "exampleRequest"}}
codes = append(codes, e.Code)
value := policy.Value(responseResult(e))
examples[e.Name] = map[string]any{"summary": e.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 {
media["schema"].(map[string]any)["properties"].(map[string]any)["code"].(map[string]any)["enum"] = codes
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 genericErrorResponse(description string) map[string]any {
func responseResult(e ErrorDefinition) response.Result {
return response.Result{Status: e.Status, Code: e.Code, Message: e.Message, RequestID: "exampleRequest"}
}
func genericErrorResponse(description string, cfg response.Config) map[string]any {
if description == "" {
description = "Error"
}
return map[string]any{"description": description, "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{
"type": "object", "required": []string{"code", "message"}, "properties": map[string]any{"code": map[string]any{"type": "string"}, "message": map[string]any{"type": "string"}, "requestId": map[string]any{"type": "string"}},
}}}}
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

@@ -1,6 +1,7 @@
package apigen
import (
"encoding/json"
"errors"
"fmt"
"go/format"
@@ -18,7 +19,7 @@ import (
)
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
const minimumVersion = "v0.7.0-preview"
const minimumVersion = "v0.8.0-preview"
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
type Options struct {
@@ -129,13 +130,13 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
if !filepath.IsAbs(path) {
path = filepath.Join(directory, path)
}
if _, err := os.Stat(filepath.Join(path, "contract", "contract.go")); err == nil {
if _, err := os.Stat(filepath.Join(path, "httpx", "response.go")); err == nil {
localCurrent = true
}
}
}
if !localCurrent {
return Output{}, fmt.Errorf("existing framework dependency predates API support; use %s or a current local replacement", minimumVersion)
return Output{}, fmt.Errorf("existing framework dependency predates configurable responses; use %s or a current local replacement", minimumVersion)
}
}
if o.FrameworkDir != "" {
@@ -318,6 +319,16 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
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)\nvar responseTemplates=map[string]response.Encoder{\"\":response.Default(),\n", frameworkModule+"/response")
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.Encoder{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)}\nfunc responseEncoder(name string,raw bool)response.Encoder{encoder:=responseTemplates[name];if encoder==nil{panic(\"unknown response template\")};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/servicecontext")
routes := "func RegisterRoutes(router openapi.Registrar, deps *servicecontext.Context) error {\n if router==nil || deps==nil {return fmt.Errorf(\"router and dependencies required\")}\n"
middlewareSet := map[string]bool{}
@@ -381,7 +392,7 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
} else {
text += fmt.Sprintf("if err:=h.service.%s(%s);err!=nil{return err};return c.NoContent()\n}\n", name, call)
}
routes += fmt.Sprintf("router.Handle(%q,%q,h%d.%s", strings.ToUpper(r.Method), r.Path, i, name)
routes += fmt.Sprintf("httpx.HandleWithResponse(router,%q,%q,responseEncoder(%q,%t),h%d.%s", strings.ToUpper(r.Method), r.Path, responseName(r), r.ResponseFormat == "raw", i, name)
for _, m := range r.Middleware {
routes += fmt.Sprintf(",deps.Middlewares[%q]", m)
}
@@ -413,8 +424,6 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
put(seeds, "internal/service/"+group+"/service.go", fmt.Sprintf("package %s\nimport %q\ntype Service struct {deps *servicecontext.Context}\nfunc New(deps *servicecontext.Context)*Service{return &Service{deps:deps}}\n", packageName, o.Module+"/internal/servicecontext"))
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")
}
// Avoid unused imports when no route uses a named middleware.
imports = strings.Replace(imports, strconv.Quote(frameworkModule+"/httpx")+";", "", 1)
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 {

View File

@@ -17,6 +17,7 @@ type Spec struct {
Title, Version, Service, Description string
Errors []ErrorDefinition
Auth []AuthDefinition
Responses []ResponseDefinition
Types []Type
Routes []Route
Sources map[string][]byte // relative to the entry API's directory
@@ -32,6 +33,8 @@ type Route struct {
Method, Path, Request, Response, Handler, Group, Summary string
Middleware []string
Auth string
ResponseFormat string
ErrorResponseFormat string
Errors []string
Description string
Tags []string
@@ -42,6 +45,7 @@ type Route struct {
type token struct {
text, position string
kind rune
space bool
}
type parser struct {
tokens []token
@@ -144,9 +148,11 @@ func lex(name, source string) ([]token, error) {
}
}
var out []token
end := 0
for {
kind := s.Scan()
out = append(out, token{s.TokenText(), s.Position.String(), kind})
out = append(out, token{s.TokenText(), s.Position.String(), kind, s.Position.Offset > end})
end = s.Position.Offset + len(s.TokenText())
if kind == scanner.EOF {
break
}
@@ -260,6 +266,10 @@ func (p *parser) parse() error {
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() == "(" {
@@ -289,7 +299,7 @@ func (p *parser) parse() error {
}
server = m
for k := range m {
if k != "prefix" && k != "group" && k != "middleware" && k != "auth" && k != "errors" {
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")
}
}
@@ -326,6 +336,29 @@ func (p *parser) metadata() (map[string]string, error) {
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
@@ -401,7 +434,7 @@ func (p *parser) parseService(server map[string]string) error {
return err
}
for p.skip(); p.peek() != "}"; p.skip() {
route := Route{Auth: server["auth"], Errors: commaList(server["errors"]), Group: server["group"], Status: 200, Position: p.tokens[p.at].position}
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"
}
@@ -426,6 +459,13 @@ func (p *parser) parseService(server map[string]string) error {
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()

View File

@@ -0,0 +1,114 @@
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
}
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,357 @@
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")
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)
}
}
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/service/items", strings.ToLower(method)+".go"), "package items\nimport("+imports+")\nfunc(s *Service)"+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 responseConsumer_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/servicecontext"
)
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:=servicecontext.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

@@ -113,9 +113,12 @@ docs/openapi.json # generated OpenAPI 3.0.3
.goOmni/apiManifest.json # ownership and content hashes
`+"```"+`
The API response describes data only. Omni adds code, message and requestId.
Do not define the envelope again in each response. Handlers reject nil successful
responses. Body DTOs use application/json and have a 1 MiB limit by default.
The API response type describes business data. Declare response Name (...) and
select it with @server(response: Name) or @response Name to share a public body;
@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 API contract, independently of the app's default response configuration.
Handlers reject nil successful responses. Body DTOs use application/json and have a 1 MiB limit by default.
Use contract.Binder.Bind's optional limit in a custom adapter for larger payloads.
Fields require exactly one source tag: json, path, form (URL query only), header.

View File

@@ -252,6 +252,9 @@ func apply(root, module, entry string, generated, seeds map[string][]byte, check
}
func digest(data []byte) string { sum := sha256.Sum256(data); return hex.EncodeToString(sum[:]) }
func ownedPath(name string) bool {
if name == "internal/handler/responseGen.go" {
return true
}
if name == "internal/apierrors/errorsGen.go" || name == "docs/openapi.json" || name == "internal/types/typesGen.go" || name == "internal/handler/routesGen.go" || name == "internal/handler/openapiGen.go" || name == "internal/middleware/registryGen.go" {
return true
}

View File

@@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
// Source builds can carry an unpublished VCS version (including +dirty).
// Keep this fallback in sync with the next release; installed CLIs use their
// downloaded module version, which also has a module checksum.
const defaultFrameworkVersion = "v0.7.0-preview.1"
const defaultFrameworkVersion = "v0.8.0-preview"
// This target is not a claim that the release already exists. Source users
// select a local framework checkout until the module templates are published.