feat: complete API-driven projects with infrastructure and business acceptance
This commit is contained in:
310
contract/bind.go
Normal file
310
contract/bind.go
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user