feat: add HTTP migration bridges and extensible API binding
This commit is contained in:
@@ -19,7 +19,7 @@ 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 {
|
||||
if b == nil || r == nil {
|
||||
if b == nil || r == nil || b.schemaOnly {
|
||||
return apierrors.Internal(errors.New("contract: nil binder or request"))
|
||||
}
|
||||
v := reflect.ValueOf(target)
|
||||
@@ -65,6 +65,10 @@ func (b *Binder) Bind(r *http.Request, params map[string]string, target any, max
|
||||
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
|
||||
@@ -126,10 +130,16 @@ func tooLarge() error { return apierrors.New(413, "BODY_TOO_LARGE", "request bod
|
||||
|
||||
func fromStrings(n *node, values []string) (any, error) {
|
||||
n = base(n)
|
||||
if n.scalar != nil {
|
||||
if len(values) != 1 {
|
||||
return nil, errors.New("duplicate scalar parameter")
|
||||
}
|
||||
return scalarText(values[0]), nil
|
||||
}
|
||||
if n.typ.Kind() == reflect.Slice {
|
||||
out := make([]any, 0, len(values))
|
||||
for _, s := range values {
|
||||
v, err := parseScalar(s, base(n.elem).typ)
|
||||
v, err := fromStrings(n.elem, []string{s})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -158,6 +168,17 @@ func assignField(f field, dst reflect.Value, value any, present bool) error {
|
||||
return checkField(f, dst)
|
||||
}
|
||||
func setValue(n *node, dst reflect.Value, value any) error {
|
||||
if n.scalar != nil {
|
||||
return n.scalar.decode(dst, value)
|
||||
}
|
||||
if n.typ.Kind() == reflect.Interface && n.typ.NumMethod() == 0 {
|
||||
if value == nil {
|
||||
dst.SetZero()
|
||||
} else {
|
||||
dst.Set(reflect.ValueOf(value))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if value == nil {
|
||||
if n.typ.Kind() == reflect.Pointer {
|
||||
dst.SetZero()
|
||||
|
||||
@@ -23,10 +23,12 @@ var valuesValidator = validator.New()
|
||||
|
||||
// Binder is immutable after compilation and safe for concurrent requests.
|
||||
type Binder struct {
|
||||
root *node
|
||||
typ reflect.Type
|
||||
root *node
|
||||
typ reflect.Type
|
||||
schemaOnly bool
|
||||
}
|
||||
type node struct {
|
||||
scalar *Scalar
|
||||
typ reflect.Type
|
||||
schema *openapi.Schema
|
||||
fields []field
|
||||
@@ -42,9 +44,11 @@ type field struct {
|
||||
binding string
|
||||
}
|
||||
|
||||
func New[T any]() (*Binder, error) { return ForType(reflect.TypeOf((*T)(nil)).Elem()) }
|
||||
func MustNew[T any]() *Binder {
|
||||
b, err := New[T]()
|
||||
func New[T any](scalars ...Scalar) (*Binder, error) {
|
||||
return ForType(reflect.TypeOf((*T)(nil)).Elem(), scalars...)
|
||||
}
|
||||
func MustNew[T any](scalars ...Scalar) *Binder {
|
||||
b, err := New[T](scalars...)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -52,20 +56,36 @@ func MustNew[T any]() *Binder {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// The root must be a struct; custom marshalers require explicit scalar registration.
|
||||
func ForType(t reflect.Type, scalars ...Scalar) (*Binder, error) { return build(t, false, scalars) }
|
||||
|
||||
// Describe compiles schemas without loading or executing application Go types.
|
||||
// The returned Binder cannot bind requests; it is for generators only.
|
||||
func Describe(t reflect.Type, scalars ...Scalar) (*Binder, error) { return build(t, true, scalars) }
|
||||
|
||||
func build(t reflect.Type, schemaOnly bool, scalars []Scalar) (*Binder, error) {
|
||||
if t == nil || t.Kind() != reflect.Struct {
|
||||
return nil, errors.New("contract: root must be a struct")
|
||||
}
|
||||
c := compiler{scalars: map[reflect.Type]Scalar{}}
|
||||
for _, scalar := range scalars {
|
||||
if err := scalar.validate(schemaOnly); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, exists := c.scalars[scalar.Type]; exists {
|
||||
return nil, fmt.Errorf("contract: duplicate scalar type %s", scalar.Type)
|
||||
}
|
||||
c.scalars[scalar.Type] = scalar
|
||||
}
|
||||
budget := 10000
|
||||
n, err := compile(t, true, map[reflect.Type]bool{}, 0, &budget)
|
||||
n, err := c.compile(t, true, map[reflect.Type]bool{}, 0, &budget)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Binder{root: n, typ: t}, nil
|
||||
return &Binder{root: n, typ: t, schemaOnly: schemaOnly}, nil
|
||||
}
|
||||
|
||||
func compile(t reflect.Type, root bool, stack map[reflect.Type]bool, depth int, budget *int) (*node, error) {
|
||||
func (c compiler) 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")
|
||||
@@ -79,21 +99,32 @@ func compile(t reflect.Type, root bool, stack map[reflect.Type]bool, depth int,
|
||||
stack[t] = true
|
||||
defer delete(stack, t)
|
||||
n := &node{typ: t, schema: &openapi.Schema{}}
|
||||
if scalar, ok := c.scalars[t]; ok {
|
||||
if root {
|
||||
return nil, errors.New("contract: root cannot be a scalar")
|
||||
}
|
||||
n.scalar = &scalar
|
||||
n.schema = scalar.inputSchema()
|
||||
return n, nil
|
||||
}
|
||||
// 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")
|
||||
if t.Kind() != reflect.Pointer && (t.Implements(marshaler) || reflect.PointerTo(t).Implements(marshaler) || t.Implements(unmarshaler) || reflect.PointerTo(t).Implements(unmarshaler)) {
|
||||
return nil, errors.New("contract: custom JSON marshalers require explicit Scalar registration")
|
||||
}
|
||||
switch t.Kind() {
|
||||
case reflect.Interface:
|
||||
if t.NumMethod() != 0 {
|
||||
return nil, errors.New("contract: only any / interface{} is supported")
|
||||
}
|
||||
case reflect.Pointer:
|
||||
e, err := compile(t.Elem(), false, stack, depth+1, budget)
|
||||
e, err := c.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
|
||||
n.schema = nullableSchema(e.schema)
|
||||
case reflect.String:
|
||||
n.schema.Type = "string"
|
||||
case reflect.Bool:
|
||||
@@ -132,10 +163,10 @@ func compile(t reflect.Type, root bool, stack map[reflect.Type]bool, depth int,
|
||||
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 {
|
||||
if t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Uint8 && c.scalars[t.Elem()].Type == nil {
|
||||
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)
|
||||
e, err := c.compile(t.Elem(), false, stack, depth+1, budget)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -158,7 +189,7 @@ func compile(t reflect.Type, root bool, stack map[reflect.Type]bool, depth int,
|
||||
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)
|
||||
f, err := c.compileField(sf, root, stack, depth+1, budget)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("contract: %s: %w", sf.Name, err)
|
||||
}
|
||||
@@ -182,7 +213,7 @@ func compile(t reflect.Type, root bool, stack map[reflect.Type]bool, depth int,
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func compileField(sf reflect.StructField, root bool, stack map[reflect.Type]bool, depth int, budget *int) (field, error) {
|
||||
func (c compiler) 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))
|
||||
@@ -218,11 +249,37 @@ func compileField(sf reflect.StructField, root bool, stack map[reflect.Type]bool
|
||||
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)
|
||||
n, err := c.compile(sf.Type, false, stack, depth, budget)
|
||||
if err != nil {
|
||||
return f, err
|
||||
}
|
||||
f.value = n
|
||||
opaque := base(n).scalar
|
||||
isList := base(n).typ.Kind() == reflect.Slice && opaque == nil
|
||||
if isList {
|
||||
opaque = base(base(n).elem).scalar
|
||||
}
|
||||
if opaque != nil {
|
||||
if isList && (f.source == "path" || f.source == "header") {
|
||||
return f, errors.New("path/header values must be scalars")
|
||||
}
|
||||
if f.source != "json" && !opaque.Text {
|
||||
return f, errors.New("custom scalar needs text: true and UnmarshalText for path/query/header")
|
||||
}
|
||||
if f.binding != "" {
|
||||
return f, errors.New("custom scalar constraints belong in its decoder; use optional/pointer for presence and null")
|
||||
}
|
||||
for _, option := range parts[1:] {
|
||||
if option != "optional" && option != "omitempty" {
|
||||
return f, errors.New("custom scalar supports only optional/omitempty field options")
|
||||
}
|
||||
f.optional = true
|
||||
}
|
||||
if f.source == "path" && (f.optional || sf.Type.Kind() == reflect.Pointer) {
|
||||
return f, errors.New("path fields must be required non-pointer values")
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
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")
|
||||
@@ -370,7 +427,7 @@ func (b *Binder) ResponseSchema() (*openapi.Schema, error) {
|
||||
return nil, errors.New("contract: response fields must use json tags")
|
||||
}
|
||||
}
|
||||
return copySchema(b.root.schema), nil
|
||||
return responseSchema(b.root), nil
|
||||
}
|
||||
func (b *Binder) Parameters() []openapi.Parameter {
|
||||
var out []openapi.Parameter
|
||||
@@ -382,7 +439,13 @@ func (b *Binder) Parameters() []openapi.Parameter {
|
||||
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)})
|
||||
schema := copySchema(f.value.schema)
|
||||
if base(f.value).scalar != nil {
|
||||
schema = &openapi.Schema{Type: "string"}
|
||||
} else if base(f.value).typ.Kind() == reflect.Slice && base(base(f.value).elem).scalar != nil {
|
||||
schema.Items = &openapi.Schema{Type: "string"}
|
||||
}
|
||||
out = append(out, openapi.Parameter{Name: f.name, In: in, Required: !f.optional && !f.hasDefault, Schema: schema})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
199
contract/scalar.go
Normal file
199
contract/scalar.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"encoding"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/openapi"
|
||||
)
|
||||
|
||||
// Scalar explicitly opts one application Go type into custom wire conversion.
|
||||
// Input is a | separated union of scalar wire types; Output is one wire type.
|
||||
// The value must implement json.Marshaler, its pointer json.Unmarshaler, and
|
||||
// Text=true additionally requires encoding.TextUnmarshaler on the pointer.
|
||||
// No process-global registry or mutation of other binders is involved.
|
||||
type Scalar struct {
|
||||
Type reflect.Type
|
||||
JSONInput, JSONOutput string
|
||||
Text bool
|
||||
}
|
||||
|
||||
func ScalarOf[T any](input, output string, text bool) Scalar {
|
||||
return Scalar{Type: reflect.TypeOf((*T)(nil)).Elem(), JSONInput: input, JSONOutput: output, Text: text}
|
||||
}
|
||||
|
||||
type compiler struct {
|
||||
scalars map[reflect.Type]Scalar
|
||||
}
|
||||
type scalarText string
|
||||
|
||||
func (s Scalar) validate(schemaOnly bool) error {
|
||||
if s.Type == nil || s.Type.Kind() == reflect.Pointer || s.Type.Kind() == reflect.Interface {
|
||||
return errors.New("contract: scalar requires a concrete non-pointer type")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, kind := range strings.Split(s.JSONInput, "|") {
|
||||
kind = strings.TrimSpace(kind)
|
||||
if wireSchema(kind) == nil || seen[kind] {
|
||||
return fmt.Errorf("contract: invalid/duplicate scalar input %q", kind)
|
||||
}
|
||||
seen[kind] = true
|
||||
}
|
||||
if wireSchema(s.JSONOutput) == nil {
|
||||
return fmt.Errorf("contract: invalid scalar output %q", s.JSONOutput)
|
||||
}
|
||||
if !schemaOnly {
|
||||
if !s.Type.Implements(reflect.TypeOf((*json.Marshaler)(nil)).Elem()) || !reflect.PointerTo(s.Type).Implements(reflect.TypeOf((*json.Unmarshaler)(nil)).Elem()) {
|
||||
return fmt.Errorf("contract: %s needs value MarshalJSON and pointer UnmarshalJSON", s.Type)
|
||||
}
|
||||
if s.Text && !reflect.PointerTo(s.Type).Implements(reflect.TypeOf((*encoding.TextUnmarshaler)(nil)).Elem()) {
|
||||
return fmt.Errorf("contract: %s needs UnmarshalText", s.Type)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func wireSchema(kind string) *openapi.Schema {
|
||||
switch kind {
|
||||
case "string":
|
||||
return &openapi.Schema{Type: "string"}
|
||||
case "bool":
|
||||
return &openapi.Schema{Type: "boolean"}
|
||||
case "int32", "int64":
|
||||
return &openapi.Schema{Type: "integer", Format: kind}
|
||||
case "float64":
|
||||
return &openapi.Schema{Type: "number", Format: "double"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s Scalar) inputSchema() *openapi.Schema {
|
||||
var alternatives []*openapi.Schema
|
||||
for _, kind := range strings.Split(s.JSONInput, "|") {
|
||||
alternatives = append(alternatives, wireSchema(strings.TrimSpace(kind)))
|
||||
}
|
||||
if len(alternatives) == 1 {
|
||||
return alternatives[0]
|
||||
}
|
||||
return &openapi.Schema{AnyOf: alternatives}
|
||||
}
|
||||
func (s Scalar) decode(dst reflect.Value, value any) (err error) {
|
||||
// User decoders may fail; do not allow their panic to escape a binder.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = errors.New("custom scalar decoder panicked")
|
||||
}
|
||||
}()
|
||||
if text, ok := value.(scalarText); ok {
|
||||
decoder, ok := dst.Addr().Interface().(encoding.TextUnmarshaler)
|
||||
if !s.Text || !ok {
|
||||
return errors.New("text decoder not available")
|
||||
}
|
||||
return decoder.UnmarshalText([]byte(text))
|
||||
}
|
||||
allowed := false
|
||||
for _, kind := range strings.Split(s.JSONInput, "|") {
|
||||
if scalarAccepts(strings.TrimSpace(kind), value) {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
return errors.New("value does not match declared scalar input")
|
||||
}
|
||||
decoder, ok := dst.Addr().Interface().(json.Unmarshaler)
|
||||
if !ok {
|
||||
return errors.New("JSON decoder not available")
|
||||
}
|
||||
raw, err := json.Marshal(value) // json.Number preserves exact integer tokens.
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return decoder.UnmarshalJSON(raw)
|
||||
}
|
||||
func scalarAccepts(kind string, value any) bool {
|
||||
switch kind {
|
||||
case "string":
|
||||
_, ok := value.(string)
|
||||
return ok
|
||||
case "bool":
|
||||
_, ok := value.(bool)
|
||||
return ok
|
||||
case "int32", "int64":
|
||||
n, ok := value.(json.Number)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
bits := 64
|
||||
if kind == "int32" {
|
||||
bits = 32
|
||||
}
|
||||
_, err := strconv.ParseInt(string(n), 10, bits)
|
||||
return err == nil
|
||||
case "float64":
|
||||
n, ok := value.(json.Number)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
_, err := strconv.ParseFloat(string(n), 64)
|
||||
return err == nil
|
||||
}
|
||||
return false
|
||||
}
|
||||
func responseSchema(n *node) *openapi.Schema {
|
||||
b := base(n)
|
||||
if b.scalar != nil {
|
||||
s := wireSchema(b.scalar.JSONOutput)
|
||||
if n.typ.Kind() == reflect.Pointer {
|
||||
s = nullableSchema(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
// Preserve the field's own constraints (including pointer range/enum and
|
||||
// collection length) while changing only nested custom scalar wire types.
|
||||
s := copySchema(n.schema)
|
||||
if b.typ.Kind() == reflect.Slice {
|
||||
s.Items = responseSchema(b.elem)
|
||||
}
|
||||
if b.typ.Kind() == reflect.Map {
|
||||
s.AdditionalProperties = responseSchema(b.elem)
|
||||
}
|
||||
for _, f := range b.fields {
|
||||
if f.source == "json" {
|
||||
s.Properties[f.name] = responseSchema(f.value)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ValidateScalars checks the application's explicitly registered method contracts.
|
||||
func ValidateScalars(scalars ...Scalar) error {
|
||||
seen := map[reflect.Type]bool{}
|
||||
for _, s := range scalars {
|
||||
if err := s.validate(false); err != nil {
|
||||
return err
|
||||
}
|
||||
if seen[s.Type] {
|
||||
return fmt.Errorf("contract: duplicate scalar type %s", s.Type)
|
||||
}
|
||||
seen[s.Type] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OpenAPI 3.0 nullable only applies alongside an explicit type. A union needs
|
||||
// an explicit null alternative; an unconstrained dynamic schema already accepts null.
|
||||
func nullableSchema(schema *openapi.Schema) *openapi.Schema {
|
||||
s := copySchema(schema)
|
||||
if s.Type != "" {
|
||||
s.Nullable = true
|
||||
return s
|
||||
}
|
||||
if len(s.AnyOf) > 0 {
|
||||
s.AnyOf = append(s.AnyOf, &openapi.Schema{Type: "string", Nullable: true, Enum: []any{nil}})
|
||||
}
|
||||
return s
|
||||
}
|
||||
222
contract/scalar_test.go
Normal file
222
contract/scalar_test.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type flexibleID int64
|
||||
|
||||
func (v flexibleID) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(strconv.FormatInt(int64(v), 10))
|
||||
}
|
||||
func (v *flexibleID) UnmarshalJSON(b []byte) error {
|
||||
var s string
|
||||
if len(b) > 0 && b[0] == '"' {
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
s = string(b)
|
||||
}
|
||||
return v.UnmarshalText([]byte(s))
|
||||
}
|
||||
func (v *flexibleID) UnmarshalText(b []byte) error {
|
||||
n, err := strconv.ParseInt(string(b), 10, 64)
|
||||
if err == nil {
|
||||
*v = flexibleID(n)
|
||||
}
|
||||
return err
|
||||
}
|
||||
func TestDynamicJSONAndCustomScalars(t *testing.T) {
|
||||
type request struct {
|
||||
ID flexibleID `json:"id"`
|
||||
Dynamic any `json:"dynamic"`
|
||||
IDs []flexibleID `json:"ids,optional"`
|
||||
}
|
||||
scalar := ScalarOf[flexibleID]("string|int64", "string", true)
|
||||
b, err := New[request](scalar)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range []string{`"9223372036854775807"`, `9223372036854775807`} {
|
||||
r := httptest.NewRequest("POST", "/", strings.NewReader(`{"id":`+id+`,"dynamic":{"n":9223372036854775807},"ids":["1",2]}`))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
var req request
|
||||
if err = b.Bind(r, nil, &req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req.ID != flexibleID(9223372036854775807) || req.Dynamic.(map[string]any)["n"] != json.Number("9223372036854775807") {
|
||||
t.Fatalf("precision: %+v", req)
|
||||
}
|
||||
encoded, _ := json.Marshal(req)
|
||||
if !strings.Contains(string(encoded), `"id":"9223372036854775807"`) {
|
||||
t.Fatal(string(encoded))
|
||||
}
|
||||
}
|
||||
for _, body := range []string{`{"id":true,"dynamic":null}`, `{"id":1.1,"dynamic":null}`, `{"id":9223372036854775808,"dynamic":null}`, `{"id":1,"dynamic":{"x":1,"x":2}}`, `{"id":1,"dynamic":null,"unexpected":1}`} {
|
||||
r := httptest.NewRequest("POST", "/", strings.NewReader(body))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
req := request{ID: 7}
|
||||
if b.Bind(r, nil, &req) == nil || req.ID != 7 {
|
||||
t.Fatalf("accepted or mutated %s", body)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{`null`, `[1,"x"]`, `false`, `"text"`} {
|
||||
r := httptest.NewRequest("POST", "/", strings.NewReader(`{"id":1,"dynamic":`+value+`}`))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
if err := b.Bind(r, nil, &request{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := New[request](); err == nil {
|
||||
t.Fatal("implicit custom type registration")
|
||||
}
|
||||
response, err := b.ResponseSchema()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Properties["id"].Type != "string" || response.Properties["ids"].Items.Type != "string" || len(b.BodySchema().Properties["id"].AnyOf) != 2 {
|
||||
t.Fatal("input/output schemas conflated")
|
||||
}
|
||||
type path struct {
|
||||
ID flexibleID `path:"id"`
|
||||
}
|
||||
p, err := New[path](scalar)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var req path
|
||||
if err := p.Bind(httptest.NewRequest("GET", "/", nil), map[string]string{"id": "9223372036854775807"}, &req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Parameters()[0].Schema.Type != "string" {
|
||||
t.Fatal("text parameters need text schema")
|
||||
}
|
||||
if _, err := New[path](ScalarOf[flexibleID]("string|int64", "string", false)); err == nil {
|
||||
t.Fatal("text support not explicit")
|
||||
}
|
||||
schemaOnly, err := Describe(reflect.TypeOf(request{}), scalar)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if schemaOnly.Bind(httptest.NewRequest("POST", "/", nil), nil, &request{}) == nil {
|
||||
t.Fatal("schema-only binder executed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScalarPointersCollectionsAndRegistration(t *testing.T) {
|
||||
scalar := ScalarOf[flexibleID]("string|int64", "string", true)
|
||||
type nullable struct {
|
||||
ID *flexibleID `json:"id,optional"`
|
||||
Values map[string]*flexibleID `json:"values,optional"`
|
||||
}
|
||||
b, err := New[nullable](scalar)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, body := range []string{`{}`, `{"id":null}`, `{"id":1,"values":{"a":"2","b":null}}`} {
|
||||
r := httptest.NewRequest("POST", "/", strings.NewReader(body))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
if err := b.Bind(r, nil, &nullable{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
input := b.BodySchema().Properties["id"]
|
||||
if len(input.AnyOf) != 3 || !input.AnyOf[2].Nullable || len(input.AnyOf[2].Enum) != 1 || input.AnyOf[2].Enum[0] != nil {
|
||||
t.Fatal("nullable input union does not describe null")
|
||||
}
|
||||
out, err := b.ResponseSchema()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Properties["id"].Type != "string" || !out.Properties["id"].Nullable {
|
||||
t.Fatal("nullable output type")
|
||||
}
|
||||
type query struct {
|
||||
IDs []flexibleID `form:"ids"`
|
||||
}
|
||||
q, err := New[query](scalar)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var req query
|
||||
if err := q.Bind(httptest.NewRequest("GET", "/?ids=1&ids=2", nil), nil, &req); err != nil || !reflect.DeepEqual(req.IDs, []flexibleID{1, 2}) {
|
||||
t.Fatalf("query scalar collection: %+v %v", req, err)
|
||||
}
|
||||
if q.Parameters()[0].Schema.Items.Type != "string" {
|
||||
t.Fatal("text array schema")
|
||||
}
|
||||
if _, err := New[query](ScalarOf[flexibleID]("string|int64", "string", false)); err == nil {
|
||||
t.Fatal("array bypassed text declaration")
|
||||
}
|
||||
if err := ValidateScalars(ScalarOf[int64]("int64", "string", true)); err == nil {
|
||||
t.Fatal("missing methods accepted")
|
||||
}
|
||||
if err := ValidateScalars(scalar, scalar); err == nil {
|
||||
t.Fatal("duplicate registration")
|
||||
}
|
||||
type strict struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
strictBinder := MustNew[strict]()
|
||||
r := httptest.NewRequest("POST", "/", strings.NewReader(`{"id":"1"}`))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
if strictBinder.Bind(r, nil, &strict{}) == nil {
|
||||
t.Fatal("other binder globally loosened")
|
||||
}
|
||||
}
|
||||
|
||||
type panicScalar string
|
||||
|
||||
func (v panicScalar) MarshalJSON() ([]byte, error) { return json.Marshal(string(v)) }
|
||||
func (v *panicScalar) UnmarshalJSON([]byte) error { panic("decoder failure") }
|
||||
func TestScalarPanicIsBindingError(t *testing.T) {
|
||||
type req struct {
|
||||
Value panicScalar `json:"value"`
|
||||
}
|
||||
b := MustNew[req](ScalarOf[panicScalar]("string", "string", false))
|
||||
r := httptest.NewRequest("POST", "/", strings.NewReader(`{"value":"bad"}`))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
dst := req{Value: "original"}
|
||||
if err := b.Bind(r, nil, &dst); err == nil || dst.Value != "original" {
|
||||
t.Fatalf("decoder panic: %+v %v", dst, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimitedJSONTrailingWhitespace(t *testing.T) {
|
||||
type req struct {
|
||||
Value any `json:"value"`
|
||||
}
|
||||
b := MustNew[req]()
|
||||
r := httptest.NewRequest("POST", "/", strings.NewReader(`{"value":null}`+strings.Repeat(" ", 65)))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
r.ContentLength = -1
|
||||
r.Body = http.MaxBytesReader(httptest.NewRecorder(), r.Body, 64)
|
||||
err := b.Bind(r, nil, &req{}, 64)
|
||||
if apierrors.From(err).Status != 413 {
|
||||
t.Fatalf("tail limit error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseSchemaPreservesOrdinaryPointerConstraints(t *testing.T) {
|
||||
type dto struct {
|
||||
Count *int `json:"count,optional,range=[1:10]"`
|
||||
Choice *string `json:"choice,options=a|b"`
|
||||
Values *[]int `json:"values,optional" binding:"max=3"`
|
||||
}
|
||||
b := MustNew[dto]()
|
||||
response, err := b.ResponseSchema()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(response, b.BodySchema()) {
|
||||
t.Fatalf("changed ordinary response constraints: %#v", response)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user