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:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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}}}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
114
cmd/go-omni/apigen/response.go
Normal file
114
cmd/go-omni/apigen/response.go
Normal 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
|
||||
}
|
||||
357
cmd/go-omni/apigen/response_test.go
Normal file
357
cmd/go-omni/apigen/response_test.go
Normal 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())}
|
||||
}
|
||||
}
|
||||
`
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user