feat: complete API-driven projects with infrastructure and business acceptance
This commit is contained in:
30
contract/README.md
Normal file
30
contract/README.md
Normal 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
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
|
||||
}
|
||||
}
|
||||
394
contract/contract.go
Normal file
394
contract/contract.go
Normal 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
244
contract/contract_test.go
Normal 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
259
contract/rules.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user