feat: complete contract-driven business development and MySQL integration

This commit is contained in:
2026-09-24 21:56:04 +08:00
parent f9afb8f8dd
commit f5478cdc76
108 changed files with 6557 additions and 550 deletions

View File

@@ -1,6 +1,7 @@
package contract
import (
"bufio"
"encoding/json"
"errors"
"fmt"
@@ -11,6 +12,7 @@ import (
"net/url"
"reflect"
"strconv"
"strings"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
)
@@ -19,9 +21,17 @@ import (
// 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 {
return b.BindWithPolicy(r, params, target, RequestPolicy{}, maxBytes...)
}
// BindWithPolicy merges route overrides over the App request policy.
func (b *Binder) BindWithPolicy(r *http.Request, params map[string]string, target any, policy RequestPolicy, maxBytes ...int64) error {
if b == nil || r == nil || b.schemaOnly {
return apierrors.Internal(errors.New("contract: nil binder or request"))
}
if b.HasMultipart() {
return apierrors.Internal(errors.New("multipart fields require BindMultipart"))
}
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"))
@@ -33,54 +43,9 @@ func (b *Binder) Bind(r *http.Request, params map[string]string, target any, max
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 {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
return tooLarge()
}
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))
}
}
policy = MergePolicy(RequestPolicyFromContext(r.Context()), policy)
if err := policy.Validate(); err != nil {
return apierrors.Internal(err)
}
query := url.Values{}
if r.URL != nil {
@@ -90,10 +55,121 @@ func (b *Binder) Bind(r *http.Request, params map[string]string, target any, max
return invalid(errors.New("malformed query"))
}
}
if policy.UnknownQuery == "reject" {
allowed := map[string]bool{}
for _, f := range b.root.fields {
if f.source == "form" {
allowed[f.name] = true
}
}
for key := range query {
if !allowed[key] {
return invalid(fmt.Errorf("unknown query field %s", key))
}
}
}
fresh := reflect.New(b.typ).Elem()
body := map[string]any{}
bodyAbsent := false
bodyMode := policy.Body
if bodyMode == "" || bodyMode == "auto" {
if len(b.root.schema.Properties) > 0 {
bodyMode = "required"
} else {
bodyMode = "auto"
}
}
if (bodyMode == "none" || bodyMode == "emptyObject") && len(b.root.schema.Properties) > 0 {
return apierrors.Internal(errors.New("request body policy conflicts with JSON fields"))
}
if bodyMode == "none" {
if r.Body != nil {
var one [1]byte
n, err := io.ReadFull(r.Body, one[:])
if n > 0 {
return invalid(errors.New("request body is forbidden"))
}
if err != io.EOF {
return invalid(err)
}
}
}
if bodyMode == "required" || bodyMode == "optional" || bodyMode == "emptyObject" {
var bodyReader io.Reader = r.Body
if bodyReader == nil {
bodyReader = strings.NewReader("")
}
if r.ContentLength > limit {
return tooLarge()
}
reader := &io.LimitedReader{R: bodyReader, N: limit + 1}
var checked io.Reader = reader
if policy.UTF8 == "reject" {
checked = &utf8Reader{r: checked}
}
buffered := bufio.NewReader(checked)
_, peekErr := buffered.Peek(1)
absent := peekErr == io.EOF
bodyAbsent = absent
if absent && bodyMode != "optional" {
return invalid(errors.New("body required"))
}
if peekErr != nil && !absent {
var maxErr *http.MaxBytesError
if errors.As(peekErr, &maxErr) {
return tooLarge()
}
return invalid(peekErr)
}
if !absent {
ct, mediaParams, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || ct != "application/json" || (policy.Charset == "utf8" && mediaParams["charset"] != "" && !strings.EqualFold(mediaParams["charset"], "utf-8")) {
return apierrors.New(415, "UNSUPPORTED_MEDIA_TYPE", "Content-Type must be application/json with an allowed charset")
}
dec := json.NewDecoder(buffered)
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 {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
return tooLarge()
}
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))
}
}
}
}
for _, f := range b.root.fields {
var value any
var present bool
if f.source == "json" {
if bodyAbsent && bodyMode == "optional" {
continue
}
value, present = body[f.name]
} else {
var raw []string