feat: complete API-driven projects with infrastructure and business acceptance

This commit is contained in:
2026-09-23 17:33:50 +08:00
parent 04a1f7999d
commit 2c74dc0859
137 changed files with 11926 additions and 105 deletions

30
contract/README.md Normal file
View File

@@ -0,0 +1,30 @@
# contract
Generated API projects use this package to compile Go field tags once and bind
each request without depending on a specific HTTP server. `New[T]` validates a
struct and returns an immutable Binder. `ForType` provides the same compiler to
the CLI. `BodySchema`, `Parameters`, and `ResponseSchema` return detached OpenAPI
snapshots. `MustNew[T]` is intended for generated, already checked declarations.
```go
type Request struct {
ID int64 `path:"id,range=[1:100]"`
Name string `json:"name" binding:"required,max=50"`
}
var request = contract.MustNew[Request]()
func Handle(c *httpx.Context) error {
var req Request
if err := request.Bind(c.Request(), httpx.RouteParams(c.Context()), &req); err != nil {
return err
}
return c.OK(req.Name)
}
```
The default body limit is 1 MiB. An optional positive final argument to Bind
overrides it. Application transport read timeouts still control slow clients.
See [API.md](../API.md) for source tags, validation rules, null/presence behavior,
strict decoding and supported type limits. This API leaves the existing
`Context.BindJSON/BindQuery/BindURI` behavior unchanged.

310
contract/bind.go Normal file
View File

@@ -0,0 +1,310 @@
package contract
import (
"encoding/json"
"errors"
"fmt"
"io"
"math"
"mime"
"net/http"
"net/url"
"reflect"
"strconv"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
)
// Bind fills a fresh DTO transactionally. A JSON body, if declared, is required,
// strictly decoded, and limited to 1 MiB unless maxBytes is supplied. Query
// parameters never overwrite path/body/header values. form means URL query only.
func (b *Binder) Bind(r *http.Request, params map[string]string, target any, maxBytes ...int64) error {
if b == nil || r == nil {
return apierrors.Internal(errors.New("contract: nil binder or request"))
}
v := reflect.ValueOf(target)
if v.Kind() != reflect.Pointer || v.IsNil() || v.Elem().Type() != b.typ {
return apierrors.Internal(errors.New("contract: target type mismatch"))
}
limit := DefaultMaxBodyBytes
if len(maxBytes) > 1 || (len(maxBytes) == 1 && (maxBytes[0] <= 0 || maxBytes[0] == math.MaxInt64)) {
return apierrors.Internal(errors.New("contract: invalid body limit"))
}
if len(maxBytes) == 1 {
limit = maxBytes[0]
}
fresh := reflect.New(b.typ).Elem()
body := map[string]any{}
if len(b.root.schema.Properties) > 0 {
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || ct != "application/json" {
return apierrors.New(415, "UNSUPPORTED_MEDIA_TYPE", "Content-Type must be application/json")
}
if r.Body == nil {
return invalid(errors.New("body required"))
}
if r.ContentLength > limit {
return tooLarge()
}
reader := &io.LimitedReader{R: r.Body, N: limit + 1}
dec := json.NewDecoder(reader)
dec.UseNumber()
value, err := decodeValue(dec, 0)
if reader.N == 0 {
return tooLarge()
}
if err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
return tooLarge()
}
return invalid(err)
}
_, err = dec.Token()
if reader.N == 0 {
return tooLarge()
}
if err != io.EOF {
return invalid(errors.New("exactly one JSON value required"))
}
var ok bool
body, ok = value.(map[string]any)
if !ok {
return invalid(errors.New("body must be an object"))
}
for key := range body {
if _, ok = b.root.schema.Properties[key]; !ok {
return invalid(fmt.Errorf("unknown body field %s", key))
}
}
}
query := url.Values{}
if r.URL != nil {
var err error
query, err = url.ParseQuery(r.URL.RawQuery)
if err != nil {
return invalid(errors.New("malformed query"))
}
}
for _, f := range b.root.fields {
var value any
var present bool
if f.source == "json" {
value, present = body[f.name]
} else {
var raw []string
switch f.source {
case "path":
if s, ok := params[f.name]; ok {
raw = []string{s}
}
case "form":
raw = query[f.name]
case "header":
raw = r.Header.Values(f.name)
}
present = len(raw) > 0
if present {
var err error
value, err = fromStrings(f.value, raw)
if err != nil {
return invalid(fmt.Errorf("%s: %w", f.name, err))
}
}
}
if err := assignField(f, fresh.Field(f.index), value, present); err != nil {
return invalid(fmt.Errorf("%s: %w", f.name, err))
}
}
v.Elem().Set(fresh)
return nil
}
func invalid(err error) error {
return apierrors.BadRequest("INVALID_REQUEST", "request parameters are invalid").WithCause(err)
}
func tooLarge() error { return apierrors.New(413, "BODY_TOO_LARGE", "request body exceeds limit") }
func fromStrings(n *node, values []string) (any, error) {
n = base(n)
if n.typ.Kind() == reflect.Slice {
out := make([]any, 0, len(values))
for _, s := range values {
v, err := parseScalar(s, base(n.elem).typ)
if err != nil {
return nil, err
}
out = append(out, v)
}
return out, nil
}
if len(values) != 1 {
return nil, errors.New("duplicate scalar parameter")
}
return parseScalar(values[0], n.typ)
}
func assignField(f field, dst reflect.Value, value any, present bool) error {
if !present {
if f.hasDefault {
value = f.fallback
} else if f.optional {
return nil
} else {
return errors.New("required field missing")
}
}
if err := setValue(f.value, dst, value); err != nil {
return err
}
return checkField(f, dst)
}
func setValue(n *node, dst reflect.Value, value any) error {
if value == nil {
if n.typ.Kind() == reflect.Pointer {
dst.SetZero()
return nil
}
return errors.New("null requires a pointer type")
}
if n.typ.Kind() == reflect.Pointer {
dst.Set(reflect.New(n.typ.Elem()))
return setValue(n.elem, dst.Elem(), value)
}
switch n.typ.Kind() {
case reflect.Struct:
object, ok := value.(map[string]any)
if !ok {
return errors.New("expected object")
}
for key := range object {
if _, ok = n.schema.Properties[key]; !ok {
return fmt.Errorf("unknown field %s", key)
}
}
for _, f := range n.fields {
item, exists := object[f.name]
if err := assignField(f, dst.Field(f.index), item, exists); err != nil {
return fmt.Errorf("%s: %w", f.name, err)
}
}
case reflect.Slice:
items, ok := value.([]any)
if !ok {
return errors.New("expected array")
}
dst.Set(reflect.MakeSlice(n.typ, len(items), len(items)))
for i, item := range items {
if err := setValue(n.elem, dst.Index(i), item); err != nil {
return fmt.Errorf("item %d: %w", i, err)
}
}
case reflect.Map:
items, ok := value.(map[string]any)
if !ok {
return errors.New("expected object")
}
dst.Set(reflect.MakeMapWithSize(n.typ, len(items)))
for key, item := range items {
v := reflect.New(n.typ.Elem()).Elem()
if err := setValue(n.elem, v, item); err != nil {
return fmt.Errorf("map value: %w", err)
}
dst.SetMapIndex(reflect.ValueOf(key).Convert(n.typ.Key()), v)
}
case reflect.String:
s, ok := value.(string)
if !ok {
return errors.New("expected string")
}
dst.SetString(s)
case reflect.Bool:
v, ok := value.(bool)
if !ok {
return errors.New("expected boolean")
}
dst.SetBool(v)
default:
num, ok := value.(json.Number)
if !ok {
return errors.New("expected number")
}
switch n.typ.Kind() {
case reflect.Float32, reflect.Float64:
v, err := strconv.ParseFloat(string(num), n.typ.Bits())
if err != nil || math.IsInf(v, 0) || math.IsNaN(v) {
return errors.New("number out of range")
}
dst.SetFloat(v)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
v, err := strconv.ParseUint(string(num), 10, n.typ.Bits())
if err != nil {
return errors.New("invalid unsigned integer")
}
dst.SetUint(v)
default:
v, err := strconv.ParseInt(string(num), 10, n.typ.Bits())
if err != nil {
return errors.New("invalid integer")
}
dst.SetInt(v)
}
}
return nil
}
// Token decoding rejects duplicate keys at every level (encoding/json normally
// keeps the last value). Depth and body limits bound client-controlled work.
func decodeValue(dec *json.Decoder, depth int) (any, error) {
if depth > 64 {
return nil, errors.New("JSON nesting exceeds 64")
}
token, err := dec.Token()
if err != nil {
return nil, err
}
switch token {
case json.Delim('{'):
object := map[string]any{}
for dec.More() {
key, err := dec.Token()
if err != nil {
return nil, err
}
name, ok := key.(string)
if !ok {
return nil, errors.New("invalid object key")
}
if _, exists := object[name]; exists {
return nil, errors.New("duplicate JSON field")
}
value, err := decodeValue(dec, depth+1)
if err != nil {
return nil, err
}
object[name] = value
}
end, err := dec.Token()
if err != nil || end != json.Delim('}') {
return nil, errors.New("unterminated object")
}
return object, nil
case json.Delim('['):
items := []any{}
for dec.More() {
v, err := decodeValue(dec, depth+1)
if err != nil {
return nil, err
}
items = append(items, v)
}
end, err := dec.Token()
if err != nil || end != json.Delim(']') {
return nil, errors.New("unterminated array")
}
return items, nil
default:
if _, bad := token.(json.Delim); bad {
return nil, errors.New("unexpected JSON delimiter")
}
return token, nil
}
}

394
contract/contract.go Normal file
View File

@@ -0,0 +1,394 @@
// Package contract compiles API field tags into a reusable request binder and
// OpenAPI schemas. Generated services and the CLI use the same compiler.
package contract
import (
"encoding/json"
"errors"
"fmt"
"reflect"
"regexp"
"strconv"
"strings"
"git.yuntuo.hk/k2safe/go-omni/openapi"
"github.com/go-playground/validator/v10"
)
const DefaultMaxBodyBytes int64 = 1 << 20
var wireName = regexp.MustCompile(`^[a-z][A-Za-z0-9]*$`)
var headerName = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9-]*$`)
var valuesValidator = validator.New()
// Binder is immutable after compilation and safe for concurrent requests.
type Binder struct {
root *node
typ reflect.Type
}
type node struct {
typ reflect.Type
schema *openapi.Schema
fields []field
elem *node
}
type field struct {
index int
name, source string
value *node
optional bool
fallback any
hasDefault bool
binding string
}
func New[T any]() (*Binder, error) { return ForType(reflect.TypeOf((*T)(nil)).Elem()) }
func MustNew[T any]() *Binder {
b, err := New[T]()
if err != nil {
panic(err)
}
return b
}
// ForType also lets generators check the exact runtime contract before writing files.
// The root must be a struct; recursive and custom-marshaling types are rejected.
func ForType(t reflect.Type) (*Binder, error) {
if t == nil || t.Kind() != reflect.Struct {
return nil, errors.New("contract: root must be a struct")
}
budget := 10000
n, err := compile(t, true, map[reflect.Type]bool{}, 0, &budget)
if err != nil {
return nil, err
}
return &Binder{root: n, typ: t}, nil
}
func compile(t reflect.Type, root bool, stack map[reflect.Type]bool, depth int, budget *int) (*node, error) {
*budget--
if *budget < 0 {
return nil, errors.New("contract: schema expansion exceeds 10000 nodes")
}
if depth > 32 {
return nil, errors.New("contract: type nesting exceeds 32")
}
if stack[t] {
return nil, errors.New("contract: recursive types are not supported")
}
stack[t] = true
defer delete(stack, t)
n := &node{typ: t, schema: &openapi.Schema{}}
// Wire behavior must be described completely by these tags.
marshaler := reflect.TypeOf((*json.Marshaler)(nil)).Elem()
unmarshaler := reflect.TypeOf((*json.Unmarshaler)(nil)).Elem()
if t.Implements(marshaler) || reflect.PointerTo(t).Implements(marshaler) || t.Implements(unmarshaler) || reflect.PointerTo(t).Implements(unmarshaler) {
return nil, errors.New("contract: custom JSON marshalers are unsupported")
}
switch t.Kind() {
case reflect.Pointer:
e, err := compile(t.Elem(), false, stack, depth+1, budget)
if err != nil {
return nil, err
}
n.elem = e
*n.schema = *e.schema
n.schema.Nullable = true
case reflect.String:
n.schema.Type = "string"
case reflect.Bool:
n.schema.Type = "boolean"
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
n.schema.Type = "integer"
if t.Bits() <= 32 {
n.schema.Format = "int32"
} else {
n.schema.Format = "int64"
}
if t.Kind() >= reflect.Uint && t.Kind() <= reflect.Uint64 {
v := float64(0)
n.schema.Minimum = &v
if t.Bits() < 64 {
max := float64((uint64(1) << uint(t.Bits())) - 1)
n.schema.Maximum = &max
if t.Bits() == 32 {
n.schema.Format = "int64"
}
} else {
n.schema.Format = ""
}
} else if t.Bits() < 32 {
min, max := -float64(int64(1)<<uint(t.Bits()-1)), float64((int64(1)<<uint(t.Bits()-1))-1)
n.schema.Minimum, n.schema.Maximum = &min, &max
}
case reflect.Float32, reflect.Float64:
n.schema.Type = "number"
if t.Kind() == reflect.Float32 {
n.schema.Format = "float"
} else {
n.schema.Format = "double"
}
case reflect.Slice, reflect.Map:
if t.Kind() == reflect.Map && t.Key().Kind() != reflect.String {
return nil, errors.New("contract: map keys must be strings")
}
if t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Uint8 {
return nil, errors.New("contract: byte slices use base64 in Go; use string for binary data")
}
e, err := compile(t.Elem(), false, stack, depth+1, budget)
if err != nil {
return nil, err
}
n.elem = e
if t.Kind() == reflect.Slice {
n.schema.Type = "array"
n.schema.Items = e.schema
} else {
n.schema.Type = "object"
n.schema.AdditionalProperties = e.schema
}
case reflect.Struct:
n.schema.Type = "object"
allowed := false
n.schema.AdditionalPropertiesAllowed = &allowed
n.schema.Properties = map[string]*openapi.Schema{}
seen := map[string]bool{}
for i := 0; i < t.NumField(); i++ {
sf := t.Field(i)
if sf.PkgPath != "" || sf.Anonymous {
return nil, fmt.Errorf("contract: %s must be an exported named field", sf.Name)
}
f, err := compileField(sf, root, stack, depth+1, budget)
if err != nil {
return nil, fmt.Errorf("contract: %s: %w", sf.Name, err)
}
f.index = i
key := f.source + ":" + strings.ToLower(f.name)
if seen[key] {
return nil, fmt.Errorf("contract: duplicate field %s", f.name)
}
seen[key] = true
n.fields = append(n.fields, f)
if f.source == "json" {
n.schema.Properties[f.name] = f.value.schema
if !f.optional && !f.hasDefault {
n.schema.Required = append(n.schema.Required, f.name)
}
}
}
default:
return nil, fmt.Errorf("contract: unsupported type %s", t)
}
return n, nil
}
func compileField(sf reflect.StructField, root bool, stack map[reflect.Type]bool, depth int, budget *int) (field, error) {
f := field{}
// Reject typos and unsupported tags instead of silently losing validation.
tags, err := ParseTags(string(sf.Tag))
if err != nil {
return f, err
}
var sourceTag string
for key, value := range tags {
switch key {
case "json", "path", "form", "header":
if value == "-" {
return f, errors.New("ignored fields are not part of an API contract")
}
if f.source != "" {
return f, errors.New("exactly one source tag is required")
}
f.source = key
sourceTag = value
case "binding":
f.binding = value
default:
return f, fmt.Errorf("unsupported tag %q", key)
}
}
if f.source == "" {
return f, errors.New("json, path, form or header tag required")
}
if !root && f.source != "json" {
return f, errors.New("nested fields must use json tags")
}
parts := strings.Split(sourceTag, ",")
f.name = parts[0]
if (f.source == "header" && !headerName.MatchString(f.name)) || (f.source != "header" && !wireName.MatchString(f.name)) {
return f, errors.New("wire names must be lowerCamelCase (headers use HTTP names)")
}
n, err := compile(sf.Type, false, stack, depth, budget)
if err != nil {
return f, err
}
f.value = n
scalar := base(n).typ.Kind()
if f.source != "json" && !isScalar(scalar) && !(scalar == reflect.Slice && isScalar(base(base(n).elem).typ.Kind())) {
return f, errors.New("path/query/header values must be scalars or scalar slices")
}
if f.source == "path" && !isScalar(scalar) {
return f, errors.New("path values must be scalars")
}
if f.source == "header" && !isScalar(scalar) {
return f, errors.New("header values must be scalars")
}
options := map[string]bool{}
for _, option := range parts[1:] {
key, value, _ := strings.Cut(option, "=")
if options[key] {
return f, fmt.Errorf("duplicate option %q", key)
}
options[key] = true
switch key {
case "optional", "omitempty":
if option != key {
return f, errors.New("optional takes no value")
}
f.optional = true
case "default":
if !isScalar(scalar) {
return f, errors.New("default requires a scalar")
}
v, e := parseScalar(value, base(n).typ)
if e != nil {
return f, fmt.Errorf("invalid default: %w", e)
}
f.fallback = v
f.hasDefault = true
n.schema.Default = v
case "options":
if !isScalar(scalar) || value == "" {
return f, errors.New("options requires scalar values")
}
for _, s := range strings.Split(value, "|") {
v, e := parseScalar(s, base(n).typ)
if e != nil {
return f, e
}
n.schema.Enum = append(n.schema.Enum, v)
}
case "range":
if !isNumber(scalar) {
return f, errors.New("range requires numeric type")
}
if err = applyRange(n.schema, value); err != nil {
return f, err
}
default:
return f, fmt.Errorf("unsupported field option %q", option)
}
}
if err = applyBinding(&f); err != nil {
return f, err
}
if n.schema.Nullable && len(n.schema.Enum) > 0 {
n.schema.Enum = append(n.schema.Enum, nil)
}
if f.source == "path" && (f.optional || f.hasDefault || sf.Type.Kind() == reflect.Pointer) {
return f, errors.New("path fields must be required non-pointer values")
}
if f.hasDefault {
v := reflect.New(sf.Type).Elem()
if err = setValue(n, v, f.fallback); err != nil {
return f, err
}
if err = checkField(f, v); err != nil {
return f, fmt.Errorf("default violates validation: %w", err)
}
}
return f, nil
}
// ParseTags strictly parses Go struct tags, including duplicate/unknown key checks
// performed by the compiler. It is also used to normalize generated Go JSON tags.
func ParseTags(raw string) (map[string]string, error) {
out := map[string]string{}
for strings.TrimSpace(raw) != "" {
raw = strings.TrimLeft(raw, " ")
i := strings.Index(raw, ":")
if i < 1 {
return nil, errors.New("invalid struct tag")
}
key := raw[:i]
raw = raw[i+1:]
if strings.ContainsAny(key, " \t\r\n\"`") || len(raw) == 0 || raw[0] != '"' {
return nil, errors.New("invalid struct tag")
}
end := 1
for end < len(raw) {
if raw[end] == '\\' {
end += 2
continue
}
if raw[end] == '"' {
break
}
end++
}
if end >= len(raw) {
return nil, errors.New("unterminated struct tag")
}
v, err := strconv.Unquote(raw[:end+1])
if err != nil {
return nil, err
}
if _, ok := out[key]; ok {
return nil, fmt.Errorf("duplicate tag %q", key)
}
out[key] = v
raw = raw[end+1:]
if raw != "" && raw[0] != ' ' {
return nil, errors.New("struct tags require spaces")
}
}
return out, nil
}
func base(n *node) *node {
for n.typ.Kind() == reflect.Pointer {
n = n.elem
}
return n
}
func isNumber(k reflect.Kind) bool {
return k >= reflect.Int && k <= reflect.Float64 && k != reflect.Uintptr
}
func isScalar(k reflect.Kind) bool { return k == reflect.String || k == reflect.Bool || isNumber(k) }
func (b *Binder) BodySchema() *openapi.Schema {
if len(b.root.schema.Properties) == 0 {
return nil
}
return copySchema(b.root.schema)
}
// ResponseSchema rejects request-only fields to keep response docs honest.
func (b *Binder) ResponseSchema() (*openapi.Schema, error) {
for _, f := range b.root.fields {
if f.source != "json" {
return nil, errors.New("contract: response fields must use json tags")
}
}
return copySchema(b.root.schema), nil
}
func (b *Binder) Parameters() []openapi.Parameter {
var out []openapi.Parameter
for _, f := range b.root.fields {
if f.source == "json" {
continue
}
in := f.source
if in == "form" {
in = "query"
}
out = append(out, openapi.Parameter{Name: f.name, In: in, Required: !f.optional && !f.hasDefault, Schema: copySchema(f.value.schema)})
}
return out
}
func copySchema(s *openapi.Schema) *openapi.Schema {
data, _ := json.Marshal(s)
var out openapi.Schema
_ = json.Unmarshal(data, &out)
return &out
}

244
contract/contract_test.go Normal file
View File

@@ -0,0 +1,244 @@
package contract
import (
"encoding/json"
"net/http/httptest"
"reflect"
"strings"
"sync"
"testing"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
)
type address struct {
City string `json:"city" binding:"required,max=30"`
}
type mixed struct {
ID int64 `path:"userId,range=[1:100]"`
Page int `form:"page,default=1,range=[1:50]"`
Tags []string `form:"tag,optional" binding:"max=3"`
Client string `header:"X-Client"`
Name string `json:"name" binding:"required,max=20"`
Active bool `json:"active"`
Count *int `json:"count,optional"`
Contact address `json:"contact"`
Items []address `json:"items,optional"`
Labels map[string]address `json:"labels,optional"`
}
const validBody = `{"name":"小明","active":false,"count":0,"contact":{"city":"杭州"}}`
func bindMixed(body, query string) (mixed, error) {
b := MustNew[mixed]()
r := httptest.NewRequest("POST", "/users/7"+query, strings.NewReader(body))
r.Header.Set("Content-Type", "application/json")
r.Header.Set("X-Client", "web")
var v mixed
return v, b.Bind(r, map[string]string{"userId": "7"}, &v)
}
func TestMixedSourcesAndPresence(t *testing.T) {
b := MustNew[mixed]()
r := httptest.NewRequest("POST", "/users/7?tag=a&tag=b&userId=99&name=query", strings.NewReader(validBody))
r.Header.Set("Content-Type", "application/json; charset=utf-8")
r.Header.Set("X-Client", "web")
v := mixed{Name: "previous"}
if err := b.Bind(r, map[string]string{"userId": "7"}, &v); err != nil {
t.Fatal(err)
}
if v.ID != 7 || v.Page != 1 || v.Name != "小明" || v.Active || v.Count == nil || *v.Count != 0 || len(v.Tags) != 2 || v.Client != "web" || v.Contact.City != "杭州" {
t.Fatalf("bad binding: %+v", v)
}
r = httptest.NewRequest("POST", "/", strings.NewReader(strings.Replace(validBody, `,"count":0`, "", 1)))
r.Header.Set("Content-Type", "application/json")
r.Header.Set("X-Client", "web")
if err := b.Bind(r, map[string]string{"userId": "7"}, &v); err != nil || v.Count != nil {
t.Fatalf("missing != zero: %+v %v", v, err)
}
}
func TestRejectInvalidRequests(t *testing.T) {
cases := []struct{ name, body, query string }{
{"missing", `{"name":"a","contact":{"city":"杭州"}}`, ""},
{"empty", strings.Replace(validBody, `"小明"`, `""`, 1), ""},
{"caseSensitive", strings.Replace(validBody, `"name"`, `"Name"`, 1), ""},
{"unknown", strings.Replace(validBody, `"active":false`, `"active":false,"extra":1`, 1), ""},
{"duplicate", strings.Replace(validBody, `"active":false`, `"active":false,"active":true`, 1), ""},
{"nestedDuplicate", strings.Replace(validBody, `"city":"杭州"`, `"city":"杭州","city":"北京"`, 1), ""},
{"nestedRequired", strings.Replace(validBody, `{"city":"杭州"}`, `{}`, 1), ""},
{"nestedUnknown", strings.Replace(validBody, `{"city":"杭州"}`, `{"city":"杭州","other":0}`, 1), ""},
{"trailing", validBody + ` {}`, ""},
{"null", strings.Replace(validBody, `false`, `null`, 1), ""},
{"numberOverflow", strings.Replace(validBody, `"count":0`, `"count":99999999999999999999`, 1), ""},
{"arrayValidation", strings.TrimSuffix(validBody, "}") + `,"items":[{"city":""}]}`, ""},
{"mapValidation", strings.TrimSuffix(validBody, "}") + `,"labels":{"home":{"city":""}}}`, ""},
{"queryDuplicate", validBody, "?page=1&page=2"},
{"queryDefaultNotOnEmpty", validBody, "?page="},
{"range", validBody, "?page=51"},
{"malformedQuery", validBody, "?page=%zz"},
{"arrayLimit", validBody, "?tag=a&tag=b&tag=c&tag=d"},
}
b := MustNew[mixed]()
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest("POST", "/"+tc.query, strings.NewReader(tc.body))
r.Header.Set("Content-Type", "application/json")
r.Header.Set("X-Client", "web")
v := mixed{Name: "unchanged"}
err := b.Bind(r, map[string]string{"userId": "7"}, &v)
if err == nil || apierrors.From(err).Status != 400 {
t.Fatalf("wanted 400: %v", err)
}
if v.Name != "unchanged" || v.Page != 0 {
t.Fatal("failed binding modified target")
}
})
}
}
func TestBodyLimitsAndMediaType(t *testing.T) {
type request struct {
Name string `json:"name"`
}
b := MustNew[request]()
for _, tc := range []struct {
body, ct string
limit int64
status int
}{
{`{"name":"x"}`, "text/plain", 100, 415},
{`{"name":"long"}`, "application/json", 10, 413},
{`{"name":"x"}` + strings.Repeat(" ", 2048), "application/json", 1024, 413},
{`{"name":"x"}`, "application/json", 12, 0},
} {
r := httptest.NewRequest("POST", "/", strings.NewReader(tc.body))
r.ContentLength = -1
r.Header.Set("Content-Type", tc.ct)
var req request
err := b.Bind(r, nil, &req, tc.limit)
if tc.status == 0 {
if err != nil {
t.Fatal(err)
}
} else if err == nil || apierrors.From(err).Status != tc.status {
t.Fatalf("status %d wanted: %v", tc.status, err)
}
}
}
func TestSchemaUsesSameRules(t *testing.T) {
b := MustNew[mixed]()
s := b.BodySchema()
if len(s.Required) != 3 || s.Properties["count"].Nullable != true || s.Properties["name"].MaxLength == nil || *s.Properties["name"].MaxLength != 20 {
t.Fatalf("wrong schema: %+v", s)
}
encoded, err := json.Marshal(s)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(encoded), `"additionalProperties":false`) {
t.Fatal(string(encoded))
}
params := b.Parameters()
if len(params) != 4 || params[1].Required || params[1].Schema.Default != json.Number("1") {
t.Fatalf("wrong parameters: %+v", params)
}
// Returned documentation snapshots do not mutate concurrent binders.
s.Properties["name"].MaxLength = nil
if b.BodySchema().Properties["name"].MaxLength == nil {
t.Fatal("shared schema")
}
if _, err = b.ResponseSchema(); err == nil {
t.Fatal("request sources in response accepted")
}
}
func TestRulesAndConcurrentReuse(t *testing.T) {
type req struct {
Score float64 `json:"score,options=1.0|2,range=(0:2]"`
Email string `json:"email" binding:"email"`
Value *int `json:"value" binding:"required"`
}
b := MustNew[req]()
var wg sync.WaitGroup
for i := 0; i < 24; i++ {
wg.Add(1)
go func() {
defer wg.Done()
r := httptest.NewRequest("POST", "/", strings.NewReader(`{"score":1,"email":"a@example.com","value":3}`))
r.Header.Set("Content-Type", "application/json")
var v req
if err := b.Bind(r, nil, &v); err != nil {
t.Error(err)
}
}()
}
wg.Wait()
for _, body := range []string{`{"score":0,"email":"a@example.com","value":1}`, `{"score":2,"email":"bad","value":1}`, `{"score":2,"email":"a@example.com","value":null}`, `{"score":2,"email":"a@example.com","value":0}`} {
r := httptest.NewRequest("POST", "/", strings.NewReader(body))
r.Header.Set("Content-Type", "application/json")
var v req
if err := b.Bind(r, nil, &v); err == nil {
t.Fatal(body)
}
}
}
func TestInvalidContracts(t *testing.T) {
for _, tag := range []string{`json:"bad_name"`, `json:"a" form:"a"`, `json:"a,optionl"`, `json:"a,range=[5:2]"`, `json:"a,default=20,range=[1:10]"`, `json:"a,optional" binding:"required"`, `json:"a" binding:"unknown"`, `path:"a,optional"`, `json:"a" json:"b"`, `jsoon:"a"`, `json:"a,options=x"`} {
typ := reflect.StructOf([]reflect.StructField{{Name: "Value", Type: reflect.TypeOf(0), Tag: reflect.StructTag(tag)}})
if _, err := ForType(typ); err == nil {
t.Fatalf("accepted %s", tag)
}
}
}
func TestExactLargeIntegerConstraints(t *testing.T) {
type req struct {
Value int64 `json:"value,range=[:9007199254740992]"`
}
b := MustNew[req]()
for _, tc := range []struct {
body string
valid bool
}{{`{"value":9007199254740992}`, true}, {`{"value":9007199254740993}`, false}} {
r := httptest.NewRequest("POST", "/", strings.NewReader(tc.body))
r.Header.Set("Content-Type", "application/json")
var v req
if err := b.Bind(r, nil, &v); (err == nil) != tc.valid {
t.Fatalf("%s: %v", tc.body, err)
}
}
type defaults struct {
Value uint64 `form:"value,default=18446744073709551615,options=18446744073709551615"`
}
d := MustNew[defaults]()
data, err := json.Marshal(d.Parameters())
if err != nil || !strings.Contains(string(data), "18446744073709551615") {
t.Fatalf("lost precision: %s %v", data, err)
}
}
func TestIntrinsicIntegerBounds(t *testing.T) {
type req struct {
Small uint8 `json:"small,range=[-1:500]"`
Wide uint64 `json:"wide"`
}
b := MustNew[req]()
s := b.BodySchema()
if *s.Properties["small"].Minimum != 0 || *s.Properties["small"].Maximum != 255 || s.Properties["wide"].Format != "" {
t.Fatalf("wrong integer bounds: %+v", s.Properties)
}
}
func FuzzStrictBody(f *testing.F) {
for _, seed := range []string{validBody, `{}`, `{"name":"a","name":"b"}`, `[null]`, `{"x":[]}`} {
f.Add(seed)
}
binder := MustNew[mixed]()
f.Fuzz(func(t *testing.T, body string) {
if len(body) > 16384 {
t.Skip()
}
req := httptest.NewRequest("POST", "/", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Client", "fuzz")
var value mixed
_ = binder.Bind(req, map[string]string{"userId": "1"}, &value, 16384)
})
}

259
contract/rules.go Normal file
View File

@@ -0,0 +1,259 @@
package contract
import (
"encoding/json"
"errors"
"fmt"
"math"
"reflect"
"strconv"
"strings"
"unicode/utf8"
"git.yuntuo.hk/k2safe/go-omni/openapi"
)
func applyRange(s *openapi.Schema, value string) error {
if len(value) < 3 || (value[0] != '[' && value[0] != '(') || (value[len(value)-1] != ']' && value[len(value)-1] != ')') {
return errors.New("invalid range: use [min:max], (min:max), or mixed bounds")
}
lo, hi, ok := strings.Cut(value[1:len(value)-1], ":")
if !ok || (lo == "" && hi == "") {
return errors.New("invalid numeric range")
}
if lo != "" {
v, err := bound(lo)
if err != nil {
return err
}
if s.Minimum == nil || v > *s.Minimum || (v == *s.Minimum && value[0] == '(') {
s.Minimum = &v
s.ExclusiveMinimum = value[0] == '('
}
}
if hi != "" {
v, err := bound(hi)
if err != nil {
return err
}
if s.Maximum == nil || v < *s.Maximum || (v == *s.Maximum && value[len(value)-1] == ')') {
s.Maximum = &v
s.ExclusiveMaximum = value[len(value)-1] == ')'
}
}
return checkBounds(s)
}
func bound(s string) (float64, error) {
v, err := strconv.ParseFloat(s, 64)
if err != nil || math.IsInf(v, 0) || math.IsNaN(v) || math.Abs(v) > 1<<53 {
return 0, errors.New("constraint bound must be finite and within ±2^53")
}
return v, nil
}
func checkBounds(s *openapi.Schema) error {
if s.Minimum != nil && s.Maximum != nil && (*s.Minimum > *s.Maximum || (*s.Minimum == *s.Maximum && (s.ExclusiveMinimum || s.ExclusiveMaximum))) {
return errors.New("empty numeric range")
}
if s.MinLength != nil && s.MaxLength != nil && *s.MinLength > *s.MaxLength {
return errors.New("empty string length range")
}
if s.MinItems != nil && s.MaxItems != nil && *s.MinItems > *s.MaxItems {
return errors.New("empty array length range")
}
return nil
}
func applyBinding(f *field) error {
if f.binding == "" {
return nil
}
n := base(f.value)
k := n.typ.Kind()
s := f.value.schema
seen := map[string]bool{}
for _, rule := range strings.Split(f.binding, ",") {
name, value, _ := strings.Cut(rule, "=")
if seen[name] {
return fmt.Errorf("duplicate binding rule %s", name)
}
seen[name] = true
switch name {
case "required":
if rule != name || f.optional || f.hasDefault {
return errors.New("required conflicts with optional/default")
}
if !isScalar(k) {
return errors.New("binding required supports scalars; other fields are already required by presence")
}
s.Nullable = false
switch k {
case reflect.String:
one := 1
s.MinLength = &one
case reflect.Bool:
s.Not = &openapi.Schema{Enum: []any{false}}
default:
s.Not = &openapi.Schema{Enum: []any{0}}
}
case "email":
if rule != name || k != reflect.String {
return errors.New("email requires a string")
}
s.Format = "email"
case "min", "max", "len":
if k == reflect.String || k == reflect.Slice {
v, err := strconv.Atoi(value)
if err != nil || v < 0 {
return errors.New("length constraints must be nonnegative integers")
}
if k == reflect.String {
if name == "min" || name == "len" {
if s.MinLength == nil || v > *s.MinLength {
s.MinLength = &v
}
}
if name == "max" || name == "len" {
s.MaxLength = &v
}
} else {
if name == "min" || name == "len" {
s.MinItems = &v
}
if name == "max" || name == "len" {
s.MaxItems = &v
}
}
} else if isNumber(k) && name != "len" {
v, err := bound(value)
if err != nil {
return err
}
if name == "min" {
if s.Minimum == nil || v > *s.Minimum {
s.Minimum = &v
s.ExclusiveMinimum = false
}
} else {
if s.Maximum == nil || v < *s.Maximum {
s.Maximum = &v
s.ExclusiveMaximum = false
}
}
} else {
return fmt.Errorf("%s requires a number, string or slice", name)
}
default:
return fmt.Errorf("unsupported binding rule %q; use required,min,max,len,email or source-tag options/range", name)
}
}
return checkBounds(s)
}
func parseScalar(raw string, t reflect.Type) (any, error) {
switch t.Kind() {
case reflect.String:
return raw, nil
case reflect.Bool:
if raw == "true" {
return true, nil
}
if raw == "false" {
return false, nil
}
return nil, errors.New("expected true or false")
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
v, err := strconv.ParseInt(raw, 10, t.Bits())
if err != nil {
return nil, errors.New("invalid integer")
}
return json.Number(strconv.FormatInt(v, 10)), nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
v, err := strconv.ParseUint(raw, 10, t.Bits())
if err != nil {
return nil, errors.New("invalid unsigned integer")
}
return json.Number(strconv.FormatUint(v, 10)), nil
case reflect.Float32, reflect.Float64:
v, err := strconv.ParseFloat(raw, t.Bits())
if err != nil || math.IsInf(v, 0) || math.IsNaN(v) {
return nil, errors.New("invalid finite number")
}
return json.Number(strconv.FormatFloat(v, 'g', -1, t.Bits())), nil
}
return nil, errors.New("expected scalar")
}
func checkField(f field, v reflect.Value) error {
if v.Kind() == reflect.Pointer {
if v.IsNil() {
if strings.Contains(","+f.binding+",", ",required,") {
return errors.New("must not be null")
}
return nil
}
v = v.Elem()
}
if strings.Contains(","+f.binding+",", ",required,") && v.IsZero() {
return errors.New("must not be zero or empty")
}
s := f.value.schema
if s.Format == "email" && valuesValidator.Var(v.Interface(), "email") != nil {
return errors.New("invalid email")
}
if len(s.Enum) > 0 {
found := false
for _, item := range s.Enum {
option := reflect.New(v.Type()).Elem()
if setValue(base(f.value), option, item) == nil && reflect.DeepEqual(option.Interface(), v.Interface()) {
found = true
break
}
}
if !found {
return errors.New("not an allowed option")
}
}
if isNumber(v.Kind()) {
var number float64
switch v.Kind() {
case reflect.Float32, reflect.Float64:
number = v.Float()
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
number = float64(v.Uint())
if v.Uint() > 1<<53 {
number = math.Inf(1)
}
default:
number = float64(v.Int())
if v.Int() > 1<<53 {
number = math.Inf(1)
} else if v.Int() < -(1 << 53) {
number = math.Inf(-1)
}
}
if s.Minimum != nil && (number < *s.Minimum || (s.ExclusiveMinimum && number == *s.Minimum)) {
return errors.New("below minimum")
}
if s.Maximum != nil && (number > *s.Maximum || (s.ExclusiveMaximum && number == *s.Maximum)) {
return errors.New("above maximum")
}
}
if v.Kind() == reflect.String {
size := utf8.RuneCountInString(v.String())
if s.MinLength != nil && size < *s.MinLength {
return errors.New("string too short")
}
if s.MaxLength != nil && size > *s.MaxLength {
return errors.New("string too long")
}
}
if v.Kind() == reflect.Slice {
size := v.Len()
if s.MinItems != nil && size < *s.MinItems {
return errors.New("too few items")
}
if s.MaxItems != nil && size > *s.MaxItems {
return errors.New("too many items")
}
}
return nil
}