feat: complete contract-driven business development and MySQL integration
This commit is contained in:
172
contract/bind.go
172
contract/bind.go
@@ -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
|
||||
|
||||
@@ -36,6 +36,8 @@ type node struct {
|
||||
}
|
||||
type field struct {
|
||||
index int
|
||||
maxBytes int64
|
||||
maxFiles int
|
||||
name, source string
|
||||
value *node
|
||||
optional bool
|
||||
@@ -113,6 +115,13 @@ func (c compiler) compile(t reflect.Type, root bool, stack map[reflect.Type]bool
|
||||
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")
|
||||
}
|
||||
if t == uploadType {
|
||||
if root {
|
||||
return nil, errors.New("upload File cannot be a root DTO")
|
||||
}
|
||||
n.schema = &openapi.Schema{Type: "string", Format: "binary"}
|
||||
return n, nil
|
||||
}
|
||||
switch t.Kind() {
|
||||
case reflect.Interface:
|
||||
if t.NumMethod() != 0 {
|
||||
@@ -223,7 +232,7 @@ func (c compiler) compileField(sf reflect.StructField, root bool, stack map[refl
|
||||
var sourceTag string
|
||||
for key, value := range tags {
|
||||
switch key {
|
||||
case "json", "path", "form", "header":
|
||||
case "json", "path", "form", "header", "file", "multipart", "multipartJSON":
|
||||
if value == "-" {
|
||||
return f, errors.New("ignored fields are not part of an API contract")
|
||||
}
|
||||
@@ -232,6 +241,8 @@ func (c compiler) compileField(sf reflect.StructField, root bool, stack map[refl
|
||||
}
|
||||
f.source = key
|
||||
sourceTag = value
|
||||
case "description", "example", "enumDescriptions":
|
||||
// Documentation is validated after the field constraints.
|
||||
case "binding":
|
||||
f.binding = value
|
||||
default:
|
||||
@@ -254,6 +265,45 @@ func (c compiler) compileField(sf reflect.StructField, root bool, stack map[refl
|
||||
return f, err
|
||||
}
|
||||
f.value = n
|
||||
if f.source == "file" {
|
||||
if sf.Type != uploadType && (sf.Type.Kind() != reflect.Slice || sf.Type.Elem() != uploadType) {
|
||||
return f, errors.New("file field requires File or []File")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, option := range parts[1:] {
|
||||
key, v, _ := strings.Cut(option, "=")
|
||||
if seen[key] {
|
||||
return f, errors.New("duplicate file option")
|
||||
}
|
||||
seen[key] = true
|
||||
switch key {
|
||||
case "optional":
|
||||
if option != key {
|
||||
return f, errors.New("optional takes no value")
|
||||
}
|
||||
f.optional = true
|
||||
case "maxBytes":
|
||||
f.maxBytes, err = fieldLimit(v)
|
||||
if err != nil {
|
||||
return f, err
|
||||
}
|
||||
case "maxFiles":
|
||||
f.maxFiles, err = strconv.Atoi(v)
|
||||
if err != nil || f.maxFiles < 1 || f.maxFiles > 10000 || sf.Type.Kind() != reflect.Slice {
|
||||
return f, errors.New("maxFiles requires []File and a limit 1..10000")
|
||||
}
|
||||
default:
|
||||
return f, fmt.Errorf("unsupported file option %q", key)
|
||||
}
|
||||
}
|
||||
if f.binding != "" {
|
||||
return f, errors.New("file constraints use optional/maxBytes/maxFiles")
|
||||
}
|
||||
return f, applyDocumentation(&f, tags)
|
||||
}
|
||||
if containsUpload(n) {
|
||||
return f, errors.New("File requires file source")
|
||||
}
|
||||
opaque := base(n).scalar
|
||||
isList := base(n).typ.Kind() == reflect.Slice && opaque == nil
|
||||
if isList {
|
||||
@@ -263,25 +313,35 @@ func (c compiler) compileField(sf reflect.StructField, root bool, stack map[refl
|
||||
if isList && (f.source == "path" || f.source == "header") {
|
||||
return f, errors.New("path/header values must be scalars")
|
||||
}
|
||||
if f.source != "json" && !opaque.Text {
|
||||
if !jsonSource(f.source) && !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 strings.HasPrefix(option, "maxBytes=") && multipartSource(f.source) {
|
||||
f.maxBytes, err = fieldLimit(strings.TrimPrefix(option, "maxBytes="))
|
||||
if err != nil {
|
||||
return f, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if option != "optional" && option != "omitempty" {
|
||||
return f, errors.New("custom scalar supports only optional/omitempty field options")
|
||||
return f, errors.New("custom scalar supports optional/omitempty and multipart maxBytes")
|
||||
}
|
||||
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")
|
||||
}
|
||||
if err := applyDocumentation(&f, tags); err != nil {
|
||||
return f, err
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
scalar := base(n).typ.Kind()
|
||||
if f.source != "json" && !isScalar(scalar) && !(scalar == reflect.Slice && isScalar(base(base(n).elem).typ.Kind())) {
|
||||
if !jsonSource(f.source) && !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) {
|
||||
@@ -303,6 +363,14 @@ func (c compiler) compileField(sf reflect.StructField, root bool, stack map[refl
|
||||
return f, errors.New("optional takes no value")
|
||||
}
|
||||
f.optional = true
|
||||
case "maxBytes":
|
||||
if !multipartSource(f.source) {
|
||||
return f, errors.New("maxBytes field option is only for multipart")
|
||||
}
|
||||
f.maxBytes, err = fieldLimit(value)
|
||||
if err != nil {
|
||||
return f, err
|
||||
}
|
||||
case "default":
|
||||
if !isScalar(scalar) {
|
||||
return f, errors.New("default requires a scalar")
|
||||
@@ -354,6 +422,9 @@ func (c compiler) compileField(sf reflect.StructField, root bool, stack map[refl
|
||||
return f, fmt.Errorf("default violates validation: %w", err)
|
||||
}
|
||||
}
|
||||
if err := applyDocumentation(&f, tags); err != nil {
|
||||
return f, err
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
@@ -432,7 +503,7 @@ func (b *Binder) ResponseSchema() (*openapi.Schema, error) {
|
||||
func (b *Binder) Parameters() []openapi.Parameter {
|
||||
var out []openapi.Parameter
|
||||
for _, f := range b.root.fields {
|
||||
if f.source == "json" {
|
||||
if f.source == "json" || multipartSource(f.source) {
|
||||
continue
|
||||
}
|
||||
in := f.source
|
||||
@@ -445,7 +516,7 @@ func (b *Binder) Parameters() []openapi.Parameter {
|
||||
} 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})
|
||||
out = append(out, openapi.Parameter{Name: f.name, In: in, Required: !f.optional && !f.hasDefault, Schema: schema, Description: f.value.schema.Description})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -455,3 +526,10 @@ func copySchema(s *openapi.Schema) *openapi.Schema {
|
||||
_ = json.Unmarshal(data, &out)
|
||||
return &out
|
||||
}
|
||||
|
||||
func containsUpload(n *node) bool {
|
||||
if n.typ == uploadType {
|
||||
return true
|
||||
}
|
||||
return n.elem != nil && containsUpload(n.elem)
|
||||
}
|
||||
|
||||
174
contract/documentation.go
Normal file
174
contract/documentation.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Documentation is part of the contract compiler, so CLI and runtime agree.
|
||||
func applyDocumentation(f *field, tags map[string]string) error {
|
||||
s := f.value.schema
|
||||
if f.maxBytes > 0 {
|
||||
v := f.maxBytes
|
||||
s.MaxBytes = &v
|
||||
}
|
||||
if f.source == "file" && f.value.typ.Kind() == reflect.Slice {
|
||||
v := f.maxFiles
|
||||
if v == 0 {
|
||||
v = 16
|
||||
}
|
||||
s.MaxItems = &v
|
||||
}
|
||||
s.Description = tags["description"]
|
||||
if raw, ok := tags["enumDescriptions"]; ok {
|
||||
if err := json.Unmarshal([]byte(raw), &s.EnumDescriptions); err != nil {
|
||||
return errors.New("enumDescriptions must be a JSON string array")
|
||||
}
|
||||
count := len(s.Enum)
|
||||
if s.Nullable && count > 0 && s.Enum[count-1] == nil {
|
||||
count--
|
||||
}
|
||||
if count == 0 || len(s.EnumDescriptions) != count {
|
||||
return errors.New("enumDescriptions must describe every declared enum value")
|
||||
}
|
||||
for _, v := range s.EnumDescriptions {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return errors.New("enum description cannot be empty")
|
||||
}
|
||||
}
|
||||
}
|
||||
if raw, ok := tags["example"]; ok {
|
||||
var value any
|
||||
if base(f.value).typ.Kind() == reflect.String && base(f.value).scalar == nil {
|
||||
value = raw
|
||||
} else {
|
||||
decoder := json.NewDecoder(strings.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var err error
|
||||
value, err = decodeValue(decoder, 0)
|
||||
if err != nil {
|
||||
return errors.New("invalid example JSON")
|
||||
}
|
||||
if _, err = decoder.Token(); err != io.EOF {
|
||||
return errors.New("example requires one value")
|
||||
}
|
||||
}
|
||||
// A schema-only custom scalar never executes project decoders.
|
||||
if !documentationHasScalar(f.value) {
|
||||
v := reflect.New(f.value.typ).Elem()
|
||||
if err := setValue(f.value, v, value); err != nil {
|
||||
return fmt.Errorf("example: %w", err)
|
||||
}
|
||||
if err := checkField(*f, v); err != nil {
|
||||
return fmt.Errorf("example: %w", err)
|
||||
}
|
||||
} else if err := checkDocumentationScalarTree(f.value, value); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Example = value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func documentationHasScalar(n *node) bool {
|
||||
if n.scalar != nil {
|
||||
return true
|
||||
}
|
||||
if n.elem != nil && documentationHasScalar(n.elem) {
|
||||
return true
|
||||
}
|
||||
for _, f := range n.fields {
|
||||
if documentationHasScalar(f.value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Metadata never invokes application decoders, including nested scalar types.
|
||||
func checkDocumentationScalarTree(n *node, value any) error {
|
||||
if value == nil {
|
||||
if n.typ.Kind() == reflect.Pointer || n.typ.Kind() == reflect.Interface {
|
||||
return nil
|
||||
}
|
||||
return errors.New("example null requires a nullable field")
|
||||
}
|
||||
if n.typ.Kind() == reflect.Pointer {
|
||||
return checkDocumentationScalarTree(n.elem, value)
|
||||
}
|
||||
if n.scalar != nil {
|
||||
for _, kind := range strings.Split(n.scalar.JSONInput, "|") {
|
||||
if scalarAccepts(strings.TrimSpace(kind), value) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("example does not match scalar input")
|
||||
}
|
||||
if !documentationHasScalar(n) {
|
||||
v := reflect.New(n.typ).Elem()
|
||||
return setValue(n, v, value)
|
||||
}
|
||||
switch n.typ.Kind() {
|
||||
case reflect.Slice:
|
||||
values, ok := value.([]any)
|
||||
if !ok {
|
||||
return errors.New("example requires an array")
|
||||
}
|
||||
if n.schema.MinItems != nil && len(values) < *n.schema.MinItems || n.schema.MaxItems != nil && len(values) > *n.schema.MaxItems {
|
||||
return errors.New("example violates item limits")
|
||||
}
|
||||
for _, v := range values {
|
||||
if err := checkDocumentationScalarTree(n.elem, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case reflect.Map:
|
||||
values, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return errors.New("example requires an object")
|
||||
}
|
||||
for _, v := range values {
|
||||
if err := checkDocumentationScalarTree(n.elem, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case reflect.Struct:
|
||||
values, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return errors.New("example requires an object")
|
||||
}
|
||||
declared := map[string]bool{}
|
||||
for _, f := range n.fields {
|
||||
declared[f.name] = true
|
||||
v, ok := values[f.name]
|
||||
if !ok {
|
||||
if !f.optional && !f.hasDefault {
|
||||
return errors.New("example missing field")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := checkDocumentationScalarTree(f.value, v); err != nil {
|
||||
return err
|
||||
}
|
||||
if !documentationHasScalar(f.value) {
|
||||
typed := reflect.New(f.value.typ).Elem()
|
||||
if err := setValue(f.value, typed, v); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkField(f, typed); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for name := range values {
|
||||
if !declared[name] {
|
||||
return errors.New("unknown example field")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
55
contract/documentation_test.go
Normal file
55
contract/documentation_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type documentationScalar string
|
||||
|
||||
var documentationDecodeCalls int
|
||||
|
||||
func (v documentationScalar) MarshalJSON() ([]byte, error) { return json.Marshal(string(v)) }
|
||||
func (v *documentationScalar) UnmarshalJSON(raw []byte) error {
|
||||
documentationDecodeCalls++
|
||||
var s string
|
||||
err := json.Unmarshal(raw, &s)
|
||||
*v = documentationScalar(s)
|
||||
return err
|
||||
}
|
||||
func TestDocumentationNeverRunsNestedScalarDecoders(t *testing.T) {
|
||||
type nested struct {
|
||||
ID documentationScalar `json:"id"`
|
||||
}
|
||||
type input struct {
|
||||
Value nested `json:"value" example:"{\"id\":\"123\"}"`
|
||||
}
|
||||
documentationDecodeCalls = 0
|
||||
scalar := ScalarOf[documentationScalar]("string", "string", false)
|
||||
if _, err := New[input](scalar); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Describe(reflect.TypeOf(input{}), scalar); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if documentationDecodeCalls != 0 {
|
||||
t.Fatal("metadata invoked business decoder")
|
||||
}
|
||||
type bad struct {
|
||||
Value nested `json:"value" example:"{\"id\":123}"`
|
||||
}
|
||||
if _, err := Describe(reflect.TypeOf(bad{}), scalar); err == nil {
|
||||
t.Fatal("invalid scalar wire example accepted")
|
||||
}
|
||||
type constrained struct {
|
||||
ID documentationScalar `json:"id"`
|
||||
Count int `json:"count,range=[1:3]"`
|
||||
}
|
||||
type invalidConstraint struct {
|
||||
Value constrained `json:"value" example:"{\"id\":\"123\",\"count\":9}"`
|
||||
}
|
||||
if _, err := Describe(reflect.TypeOf(invalidConstraint{}), scalar); err == nil {
|
||||
t.Fatal("nested scalar bypassed another field's example constraints")
|
||||
}
|
||||
}
|
||||
80
contract/file.go
Normal file
80
contract/file.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// File is a request-scoped upload, staged on disk using a bounded copy buffer.
|
||||
// Name and ContentType are untrusted client metadata. Open streams content;
|
||||
// readers and the temporary file are closed when the generated handler returns.
|
||||
// Persist wanted content before returning. Retaining File does not extend its lifetime.
|
||||
type File struct {
|
||||
Name string
|
||||
ContentType string
|
||||
Size int64
|
||||
state *uploadState
|
||||
}
|
||||
type uploadState struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
closed bool
|
||||
readers map[*uploadReader]bool
|
||||
}
|
||||
type uploadReader struct {
|
||||
file *os.File
|
||||
once sync.Once
|
||||
err error
|
||||
state *uploadState
|
||||
}
|
||||
|
||||
// Present reports whether the optional upload was supplied.
|
||||
func (f File) Present() bool { return f.state != nil }
|
||||
|
||||
func (f File) Open() (io.ReadCloser, error) {
|
||||
if f.state == nil {
|
||||
return nil, errors.New("upload file is absent")
|
||||
}
|
||||
s := f.state
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed {
|
||||
return nil, errors.New("upload file is closed")
|
||||
}
|
||||
file, err := os.Open(s.path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := &uploadReader{file: file, state: s}
|
||||
s.readers[r] = true
|
||||
return r, nil
|
||||
}
|
||||
func (r *uploadReader) Read(p []byte) (int, error) { return r.file.Read(p) }
|
||||
func (r *uploadReader) Close() error {
|
||||
r.once.Do(func() { r.err = r.file.Close(); r.state.mu.Lock(); delete(r.state.readers, r); r.state.mu.Unlock() })
|
||||
return r.err
|
||||
}
|
||||
func (f File) cleanup() error {
|
||||
if f.state == nil {
|
||||
return nil
|
||||
}
|
||||
s := f.state
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
s.closed = true
|
||||
readers := make([]*uploadReader, 0, len(s.readers))
|
||||
for r := range s.readers {
|
||||
readers = append(readers, r)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
var err error
|
||||
for _, r := range readers {
|
||||
err = errors.Join(err, r.Close())
|
||||
}
|
||||
return errors.Join(err, os.Remove(s.path))
|
||||
}
|
||||
331
contract/multipart.go
Normal file
331
contract/multipart.go
Normal file
@@ -0,0 +1,331 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode/utf8"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/openapi"
|
||||
)
|
||||
|
||||
var uploadType = reflect.TypeOf(File{})
|
||||
|
||||
// MultipartOptions limits raw request bytes (including headers), part count,
|
||||
// and individual decoded part sizes. Zero selects bounded defaults. Files are
|
||||
// spooled to 0600 temporary files; text/JSON parts are buffered up to MaxFieldBytes.
|
||||
type MultipartOptions struct {
|
||||
MaxBodyBytes int64
|
||||
MaxParts int
|
||||
MaxFieldBytes int64
|
||||
MaxFileBytes int64
|
||||
TempDir string
|
||||
Policy RequestPolicy
|
||||
}
|
||||
|
||||
// Normalized returns validated effective protocol limits without allocating resources.
|
||||
func (o MultipartOptions) Normalized() (MultipartOptions, error) {
|
||||
if o.MaxBodyBytes == 0 {
|
||||
o.MaxBodyBytes = DefaultMaxBodyBytes
|
||||
}
|
||||
if o.MaxParts == 0 {
|
||||
o.MaxParts = 128
|
||||
}
|
||||
if o.MaxFieldBytes == 0 {
|
||||
o.MaxFieldBytes = 64 << 10
|
||||
}
|
||||
if o.MaxFileBytes == 0 {
|
||||
o.MaxFileBytes = o.MaxBodyBytes
|
||||
}
|
||||
if o.MaxBodyBytes < 1 || o.MaxBodyBytes > 1<<40 || o.MaxParts < 1 || o.MaxParts > 10000 || o.MaxFieldBytes < 1 || o.MaxFieldBytes > 1<<30 || o.MaxFileBytes < 1 || o.MaxFileBytes > 1<<40 {
|
||||
return o, errors.New("invalid multipart limits")
|
||||
}
|
||||
return o, o.Policy.Validate()
|
||||
}
|
||||
func (b *Binder) HasMultipart() bool {
|
||||
for _, f := range b.root.fields {
|
||||
if multipartSource(f.source) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func multipartSource(s string) bool { return s == "file" || s == "multipart" || s == "multipartJSON" }
|
||||
func jsonSource(s string) bool { return s == "json" || s == "multipartJSON" }
|
||||
|
||||
// MultipartSchema describes form parts. JSON fields get an encoding declaration
|
||||
// in generated OpenAPI; they are decoded with the ordinary strict JSON binder.
|
||||
func (b *Binder) MultipartSchema() *openapi.Schema {
|
||||
s := &openapi.Schema{Type: "object", Properties: map[string]*openapi.Schema{}}
|
||||
no := false
|
||||
s.AdditionalPropertiesAllowed = &no
|
||||
for _, f := range b.root.fields {
|
||||
if multipartSource(f.source) {
|
||||
s.Properties[f.name] = copySchema(f.value.schema)
|
||||
if !f.optional && !f.hasDefault {
|
||||
s.Required = append(s.Required, f.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
func (b *Binder) MultipartJSONFields() []string {
|
||||
var out []string
|
||||
for _, f := range b.root.fields {
|
||||
if f.source == "multipartJSON" {
|
||||
out = append(out, f.name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// BindMultipart validates every part before publishing the DTO or entering Logic.
|
||||
// On success the caller must defer cleanup; on failure all created files/readers
|
||||
// are cleaned here. The request's Body remains owned by the HTTP server.
|
||||
func (b *Binder) BindMultipart(r *http.Request, params map[string]string, target any, options MultipartOptions) (cleanup func() error, err error) {
|
||||
if b == nil || r == nil || b.schemaOnly {
|
||||
return nil, apierrors.Internal(errors.New("invalid multipart binder"))
|
||||
}
|
||||
dst := reflect.ValueOf(target)
|
||||
if dst.Kind() != reflect.Pointer || dst.IsNil() || dst.Elem().Type() != b.typ {
|
||||
return nil, apierrors.Internal(errors.New("multipart target mismatch"))
|
||||
}
|
||||
o, err := options.Normalized()
|
||||
if err != nil {
|
||||
return nil, apierrors.Internal(err)
|
||||
}
|
||||
if b.BodySchema() != nil {
|
||||
return nil, apierrors.Internal(errors.New("multipart request cannot contain json source fields; use multipartJSON"))
|
||||
}
|
||||
if r.ContentLength > o.MaxBodyBytes {
|
||||
return nil, tooLarge()
|
||||
}
|
||||
var files []File
|
||||
var once sync.Once
|
||||
var closeErr error
|
||||
release := func() error {
|
||||
once.Do(func() {
|
||||
for i := len(files) - 1; i >= 0; i-- {
|
||||
closeErr = errors.Join(closeErr, files[i].cleanup())
|
||||
}
|
||||
})
|
||||
return closeErr
|
||||
}
|
||||
success := false
|
||||
defer func() {
|
||||
if !success {
|
||||
err = errors.Join(err, release())
|
||||
}
|
||||
}()
|
||||
// Bind query/path/header once, independently of multipart body contents.
|
||||
ordinary := *b
|
||||
root := *b.root
|
||||
root.fields = nil
|
||||
ordinary.root = &root
|
||||
for _, f := range b.root.fields {
|
||||
if !multipartSource(f.source) {
|
||||
root.fields = append(root.fields, f)
|
||||
}
|
||||
}
|
||||
fresh := reflect.New(b.typ)
|
||||
policy := o.Policy
|
||||
policy.Body = "auto"
|
||||
if err = ordinary.BindWithPolicy(r, params, fresh.Interface(), policy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request := *r
|
||||
input := r.Body
|
||||
if input == nil {
|
||||
input = http.NoBody
|
||||
}
|
||||
request.Body = http.MaxBytesReader(nil, input, o.MaxBodyBytes)
|
||||
body, err := httpx.NewBody(&request, "multipart")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fields := map[string]field{}
|
||||
for _, f := range b.root.fields {
|
||||
if multipartSource(f.source) {
|
||||
fields[f.name] = f
|
||||
}
|
||||
}
|
||||
text := map[string][]string{}
|
||||
uploaded := map[string][]File{}
|
||||
seen := map[string]int{}
|
||||
policy = MergePolicy(RequestPolicyFromContext(r.Context()), o.Policy)
|
||||
for count := 0; ; count++ {
|
||||
part, e := body.Parts.NextRawPart()
|
||||
if e == io.EOF {
|
||||
break
|
||||
}
|
||||
if e != nil {
|
||||
return nil, multipartFailure(e)
|
||||
}
|
||||
if count >= o.MaxParts {
|
||||
return nil, tooLarge()
|
||||
}
|
||||
f, ok := fields[part.FormName()]
|
||||
if !ok {
|
||||
return nil, invalid(errors.New("unknown multipart field"))
|
||||
}
|
||||
seen[f.name]++
|
||||
// Reject encodings that would make declared byte limits ambiguous.
|
||||
if part.Header.Get("Content-Transfer-Encoding") != "" {
|
||||
return nil, invalid(errors.New("encoded multipart parts are unsupported"))
|
||||
}
|
||||
_, disposition, e := mime.ParseMediaType(part.Header.Get("Content-Disposition"))
|
||||
if e != nil {
|
||||
return nil, invalid(e)
|
||||
}
|
||||
_, isFile := disposition["filename"]
|
||||
if (f.source == "file") != isFile {
|
||||
return nil, invalid(errors.New("multipart field kind mismatch"))
|
||||
}
|
||||
limit := o.MaxFieldBytes
|
||||
if f.source == "file" {
|
||||
limit = o.MaxFileBytes
|
||||
if f.maxBytes > 0 && f.maxBytes < limit {
|
||||
limit = f.maxBytes
|
||||
}
|
||||
maxFiles := f.maxFiles
|
||||
if maxFiles == 0 {
|
||||
maxFiles = 16
|
||||
}
|
||||
if f.value.typ.Kind() != reflect.Slice {
|
||||
maxFiles = 1
|
||||
}
|
||||
if seen[f.name] > maxFiles {
|
||||
return nil, invalid(errors.New("too many files for multipart field"))
|
||||
}
|
||||
name := path.Base(strings.ReplaceAll(part.FileName(), "\\", "/"))
|
||||
if name == "" || name == "." || len(name) > 255 || !utf8.ValidString(name) || strings.ContainsAny(name, "\x00\r\n") {
|
||||
return nil, invalid(errors.New("invalid upload filename"))
|
||||
}
|
||||
temp, e := os.CreateTemp(o.TempDir, "omni-upload-*")
|
||||
if e != nil {
|
||||
return nil, apierrors.Internal(e)
|
||||
}
|
||||
file := File{Name: name, ContentType: part.Header.Get("Content-Type"), state: &uploadState{path: temp.Name(), readers: map[*uploadReader]bool{}}}
|
||||
files = append(files, file)
|
||||
size, e := io.Copy(temp, io.LimitReader(part, limit+1))
|
||||
e = errors.Join(e, temp.Close())
|
||||
if size > limit {
|
||||
return nil, tooLarge()
|
||||
}
|
||||
if e != nil {
|
||||
return nil, multipartFailure(e)
|
||||
}
|
||||
file.Size = size
|
||||
files[len(files)-1] = file
|
||||
uploaded[f.name] = append(uploaded[f.name], file)
|
||||
} else {
|
||||
if f.maxBytes > 0 && f.maxBytes < limit {
|
||||
limit = f.maxBytes
|
||||
}
|
||||
if seen[f.name] > 1 && (f.source == "multipartJSON" || base(f.value).typ.Kind() != reflect.Slice) {
|
||||
return nil, invalid(errors.New("duplicate multipart field"))
|
||||
}
|
||||
data, e := io.ReadAll(io.LimitReader(part, limit+1))
|
||||
if int64(len(data)) > limit {
|
||||
return nil, tooLarge()
|
||||
}
|
||||
if e != nil {
|
||||
return nil, multipartFailure(e)
|
||||
}
|
||||
if policy.UTF8 == "reject" && !utf8.Valid(data) {
|
||||
return nil, invalid(errUTF8)
|
||||
}
|
||||
if policy.Charset == "utf8" && part.Header.Get("Content-Type") != "" {
|
||||
_, p, e := mime.ParseMediaType(part.Header.Get("Content-Type"))
|
||||
if e != nil || (p["charset"] != "" && !strings.EqualFold(p["charset"], "utf-8")) {
|
||||
return nil, invalid(errors.New("invalid multipart field charset"))
|
||||
}
|
||||
}
|
||||
text[f.name] = append(text[f.name], strings.ToValidUTF8(string(data), "<22>"))
|
||||
}
|
||||
if err = part.Close(); err != nil {
|
||||
return nil, multipartFailure(err)
|
||||
}
|
||||
}
|
||||
// Consume epilogue as well; it counts against the total request cap.
|
||||
if err = body.Complete(); err != nil {
|
||||
return nil, multipartFailure(err)
|
||||
}
|
||||
for _, f := range b.root.fields {
|
||||
if !multipartSource(f.source) {
|
||||
continue
|
||||
}
|
||||
present := seen[f.name] > 0
|
||||
dest := fresh.Elem().Field(f.index)
|
||||
if f.source == "file" {
|
||||
if !present {
|
||||
if !f.optional {
|
||||
return nil, invalid(fmt.Errorf("%s: file required", f.name))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if f.value.typ.Kind() == reflect.Slice {
|
||||
dest.Set(reflect.ValueOf(uploaded[f.name]))
|
||||
} else {
|
||||
dest.Set(reflect.ValueOf(uploaded[f.name][0]))
|
||||
}
|
||||
continue
|
||||
}
|
||||
var value any
|
||||
if present {
|
||||
if f.source == "multipartJSON" {
|
||||
d := json.NewDecoder(strings.NewReader(text[f.name][0]))
|
||||
d.UseNumber()
|
||||
value, err = decodeValue(d, 0)
|
||||
if err == nil {
|
||||
_, err = d.Token()
|
||||
if err == io.EOF {
|
||||
err = nil
|
||||
} else if err == nil {
|
||||
err = errors.New("multiple JSON values")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
value, err = fromStrings(f.value, text[f.name])
|
||||
}
|
||||
if err != nil {
|
||||
return nil, invalid(fmt.Errorf("%s: %w", f.name, err))
|
||||
}
|
||||
}
|
||||
if err = assignField(f, dest, value, present); err != nil {
|
||||
return nil, invalid(fmt.Errorf("%s: %w", f.name, err))
|
||||
}
|
||||
}
|
||||
dst.Elem().Set(fresh.Elem())
|
||||
success = true
|
||||
return release, nil
|
||||
}
|
||||
func multipartFailure(err error) error {
|
||||
var max *http.MaxBytesError
|
||||
if errors.As(err, &max) {
|
||||
return tooLarge()
|
||||
}
|
||||
var api *apierrors.APIError
|
||||
if errors.As(err, &api) {
|
||||
return err
|
||||
}
|
||||
return invalid(err)
|
||||
}
|
||||
func fieldLimit(raw string) (int64, error) {
|
||||
v, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || v < 1 || v > 1<<40 {
|
||||
return 0, errors.New("maxBytes must be 1..1099511627776")
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
152
contract/multipart_test.go
Normal file
152
contract/multipart_test.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type uploadInput struct {
|
||||
Query string `form:"query,optional"`
|
||||
Name string `multipart:"name" binding:"required,max=10"`
|
||||
Meta struct {
|
||||
Count int `json:"count"`
|
||||
} `multipartJSON:"meta"`
|
||||
Attachment File `file:"attachment,maxBytes=12"`
|
||||
Extra []File `file:"extra,optional,maxBytes=8,maxFiles=2"`
|
||||
}
|
||||
type uploadPart struct{ name, filename, value string }
|
||||
|
||||
func uploadRequest(t *testing.T, parts []uploadPart) (*http.Request, []byte) {
|
||||
t.Helper()
|
||||
var data bytes.Buffer
|
||||
w := multipart.NewWriter(&data)
|
||||
for _, part := range parts {
|
||||
if part.filename != "" {
|
||||
p, e := w.CreateFormFile(part.name, part.filename)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
_, _ = io.WriteString(p, part.value)
|
||||
} else {
|
||||
_ = w.WriteField(part.name, part.value)
|
||||
}
|
||||
}
|
||||
_ = w.Close()
|
||||
r := httptest.NewRequest("POST", "/upload?query=yes", bytes.NewReader(data.Bytes()))
|
||||
r.Header.Set("Content-Type", w.FormDataContentType())
|
||||
return r, data.Bytes()
|
||||
}
|
||||
func goodParts() []uploadPart {
|
||||
return []uploadPart{{"name", "", "合同"}, {"meta", "", `{"count":2}`}, {"attachment", "../contract.txt", "hello"}}
|
||||
}
|
||||
func assertUploadEmpty(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
files, err := os.ReadDir(dir)
|
||||
if err != nil || len(files) != 0 {
|
||||
t.Fatalf("temp files remain: %v %v", files, err)
|
||||
}
|
||||
}
|
||||
func TestMultipartBindingAndLifetime(t *testing.T) {
|
||||
b := MustNew[uploadInput]()
|
||||
dir := t.TempDir()
|
||||
r, _ := uploadRequest(t, append(goodParts(), uploadPart{"extra", "one", "first"}, uploadPart{"extra", "two", "second"}))
|
||||
var input uploadInput
|
||||
release, err := b.BindMultipart(r, nil, &input, MultipartOptions{TempDir: dir, Policy: RequestPolicy{UnknownQuery: "reject", UTF8: "reject"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if input.Name != "合同" || input.Query != "yes" || input.Meta.Count != 2 || input.Attachment.Size != 5 || input.Attachment.Name != "contract.txt" || len(input.Extra) != 2 {
|
||||
t.Fatalf("%+v", input)
|
||||
}
|
||||
reader, err := input.Attachment.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil || string(data) != "hello" {
|
||||
t.Fatal(string(data), err)
|
||||
}
|
||||
// An unclosed reader is owned by the upload cleanup as well.
|
||||
if err = release(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = reader.Read(make([]byte, 1)); err == nil {
|
||||
t.Fatal("reader remains open")
|
||||
}
|
||||
if _, err = input.Attachment.Open(); err == nil {
|
||||
t.Fatal("expired upload reopened")
|
||||
}
|
||||
if err = release(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertUploadEmpty(t, dir)
|
||||
if err = b.Bind(r, nil, &input); err == nil {
|
||||
t.Fatal("ordinary binding accepted multipart fields")
|
||||
}
|
||||
}
|
||||
func TestMultipartFailuresAreTransactional(t *testing.T) {
|
||||
b := MustNew[uploadInput]()
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
parts []uploadPart
|
||||
options MultipartOptions
|
||||
status int
|
||||
}{
|
||||
{"missing", goodParts()[1:], MultipartOptions{}, 400},
|
||||
{"unknown", append(goodParts(), uploadPart{"other", "", "x"}), MultipartOptions{}, 400},
|
||||
{"duplicate", append(goodParts(), uploadPart{"name", "", "x"}), MultipartOptions{}, 400},
|
||||
{"jsonDuplicate", []uploadPart{{"name", "", "n"}, {"attachment", "f", "x"}, {"meta", "", `{"count":1,"count":2}`}}, MultipartOptions{}, 400},
|
||||
{"badType", []uploadPart{{"name", "", "n"}, {"attachment", "f", "x"}, {"meta", "", `{"count":"x"}`}}, MultipartOptions{}, 400},
|
||||
{"badUTF8", append(goodParts()[:1], uploadPart{"meta", "", "{\"count\":\"\xff\"}"}, goodParts()[2]), MultipartOptions{Policy: RequestPolicy{UTF8: "reject"}}, 400},
|
||||
{"fileSize", []uploadPart{{"attachment", "x", strings.Repeat("x", 13)}}, MultipartOptions{}, 413},
|
||||
{"fieldSize", goodParts(), MultipartOptions{MaxFieldBytes: 2}, 413},
|
||||
{"partCount", goodParts(), MultipartOptions{MaxParts: 2}, 413},
|
||||
{"total", goodParts(), MultipartOptions{MaxBodyBytes: 32}, 413},
|
||||
{"fileKind", []uploadPart{{"attachment", "", "text"}}, MultipartOptions{}, 400},
|
||||
{"extraCount", append(goodParts(), uploadPart{"extra", "a", "a"}, uploadPart{"extra", "b", "b"}, uploadPart{"extra", "c", "c"}), MultipartOptions{}, 400},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
tt.options.TempDir = dir
|
||||
r, _ := uploadRequest(t, tt.parts)
|
||||
r.ContentLength = -1
|
||||
input := uploadInput{Name: "untouched"}
|
||||
release, err := b.BindMultipart(r, nil, &input, tt.options)
|
||||
var api *apierrors.APIError
|
||||
if !errors.As(err, &api) || api.Status != tt.status {
|
||||
t.Fatalf("error %v", err)
|
||||
}
|
||||
if release != nil || input.Name != "untouched" {
|
||||
t.Fatal("partial binding published")
|
||||
}
|
||||
assertUploadEmpty(t, dir)
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestMultipartTruncationAndEpilogueLimit(t *testing.T) {
|
||||
b := MustNew[uploadInput]()
|
||||
for _, epilogue := range []bool{false, true} {
|
||||
dir := t.TempDir()
|
||||
r, original := uploadRequest(t, goodParts())
|
||||
data := original[:len(original)-8]
|
||||
limit := int64(len(original) + 16)
|
||||
if epilogue {
|
||||
data = append(original, bytes.Repeat([]byte("x"), 64)...)
|
||||
}
|
||||
r.Body = io.NopCloser(bytes.NewReader(data))
|
||||
r.ContentLength = -1
|
||||
var input uploadInput
|
||||
if _, err := b.BindMultipart(r, nil, &input, MultipartOptions{TempDir: dir, MaxBodyBytes: limit}); err == nil {
|
||||
t.Fatal("truncation or epilogue bypass accepted")
|
||||
}
|
||||
assertUploadEmpty(t, dir)
|
||||
}
|
||||
}
|
||||
105
contract/policy.go
Normal file
105
contract/policy.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// RequestPolicy describes protocol strictness, independently of business
|
||||
// fields. Empty values inherit App defaults, then the historical binder policy.
|
||||
type RequestPolicy struct {
|
||||
UnknownQuery string `json:"unknownQuery"` // ignore or reject
|
||||
UTF8 string `json:"utf8"` // replace or reject
|
||||
Charset string `json:"charset"` // any or utf8
|
||||
Body string `json:"body"` // auto, required, optional, none, emptyObject
|
||||
}
|
||||
|
||||
func (p RequestPolicy) Validate() error {
|
||||
for _, item := range []struct {
|
||||
name, value string
|
||||
allowed []string
|
||||
}{
|
||||
{"unknownQuery", p.UnknownQuery, []string{"ignore", "reject"}},
|
||||
{"utf8", p.UTF8, []string{"replace", "reject"}},
|
||||
{"charset", p.Charset, []string{"any", "utf8"}},
|
||||
{"body", p.Body, []string{"auto", "required", "optional", "none", "emptyObject"}},
|
||||
} {
|
||||
if item.value == "" {
|
||||
continue
|
||||
}
|
||||
ok := false
|
||||
for _, value := range item.allowed {
|
||||
ok = ok || value == item.value
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("request policy: invalid %s", item.name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func MergePolicy(base, override RequestPolicy) RequestPolicy {
|
||||
if override.UnknownQuery != "" {
|
||||
base.UnknownQuery = override.UnknownQuery
|
||||
}
|
||||
if override.UTF8 != "" {
|
||||
base.UTF8 = override.UTF8
|
||||
}
|
||||
if override.Charset != "" {
|
||||
base.Charset = override.Charset
|
||||
}
|
||||
if override.Body != "" {
|
||||
base.Body = override.Body
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
type policyKey struct{}
|
||||
|
||||
func WithRequestPolicy(ctx context.Context, p RequestPolicy) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return context.WithValue(ctx, policyKey{}, p)
|
||||
}
|
||||
func RequestPolicyFromContext(ctx context.Context) RequestPolicy {
|
||||
if ctx == nil {
|
||||
return RequestPolicy{}
|
||||
}
|
||||
p, _ := ctx.Value(policyKey{}).(RequestPolicy)
|
||||
return p
|
||||
}
|
||||
|
||||
// utf8Reader checks original bytes in one pass, including runes crossing Read
|
||||
// boundaries. It never replaces invalid bytes or buffers the complete body.
|
||||
type utf8Reader struct {
|
||||
r io.Reader
|
||||
pending [4]byte
|
||||
n int
|
||||
}
|
||||
|
||||
var errUTF8 = errors.New("invalid UTF-8 in JSON body")
|
||||
|
||||
func (r *utf8Reader) Read(p []byte) (int, error) {
|
||||
n, err := r.r.Read(p)
|
||||
for _, b := range p[:n] {
|
||||
if r.n == 0 && b < utf8.RuneSelf {
|
||||
continue
|
||||
}
|
||||
r.pending[r.n] = b
|
||||
r.n++
|
||||
if utf8.FullRune(r.pending[:r.n]) {
|
||||
ch, size := utf8.DecodeRune(r.pending[:r.n])
|
||||
if ch == utf8.RuneError && size == 1 {
|
||||
return n, errUTF8
|
||||
}
|
||||
r.n = 0
|
||||
}
|
||||
}
|
||||
if err == io.EOF && r.n != 0 {
|
||||
return n, errUTF8
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
104
contract/policy_test.go
Normal file
104
contract/policy_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type byteReader struct{ r io.Reader }
|
||||
|
||||
func (r byteReader) Read(p []byte) (int, error) {
|
||||
if len(p) > 1 {
|
||||
p = p[:1]
|
||||
}
|
||||
return r.r.Read(p)
|
||||
}
|
||||
|
||||
func TestBodyPolicyPropagatesOuterLimit(t *testing.T) {
|
||||
for _, limit := range []int64{0, 1, 4} {
|
||||
r := httptest.NewRequest("POST", "/", byteReader{strings.NewReader("{} ")})
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
r.Body = http.MaxBytesReader(httptest.NewRecorder(), r.Body, limit)
|
||||
err := MustNew[struct{}]().BindWithPolicy(r, nil, &struct{}{}, RequestPolicy{Body: "required", UTF8: "reject"})
|
||||
var e *apierrors.APIError
|
||||
if !errors.As(err, &e) || e.Status != 413 {
|
||||
t.Fatalf("limit %d: expected 413, got %v", limit, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictRequestPolicy(t *testing.T) {
|
||||
type request struct {
|
||||
Name string `json:"name"`
|
||||
Tags []string `form:"tag,optional"`
|
||||
}
|
||||
b := MustNew[request]()
|
||||
strict := RequestPolicy{UnknownQuery: "reject", UTF8: "reject", Charset: "utf8"}
|
||||
for _, tc := range []struct {
|
||||
name, url, ct, body string
|
||||
valid bool
|
||||
}{
|
||||
{"valid", "/?tag=a&tag=b", "application/json; charset=UTF-8", `{"name":"企业😀"}`, true},
|
||||
{"query", "/?typo=1", "application/json", `{"name":"ok"}`, false},
|
||||
{"charset", "/", "application/json; charset=gbk", `{"name":"ok"}`, false},
|
||||
{"utf8", "/", "application/json", "{\"name\":\"\xff\"}", false},
|
||||
{"truncatedRune", "/", "application/json", "{\"name\":\"\xe4\"}", false},
|
||||
{"tail", "/", "application/json", `{"name":"ok"} {}`, false},
|
||||
{"tailInvalidByte", "/", "application/json", "{\"name\":\"ok\"} \xff", false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest("POST", tc.url, byteReader{strings.NewReader(tc.body)})
|
||||
r.Header.Set("Content-Type", tc.ct)
|
||||
var dst request
|
||||
err := b.BindWithPolicy(r, nil, &dst, strict)
|
||||
if (err == nil) != tc.valid {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tc.valid && len(dst.Tags) != 2 {
|
||||
t.Fatal("query slice rejected")
|
||||
}
|
||||
})
|
||||
}
|
||||
// Explicit route overrides inherit the rest of the App policy.
|
||||
r := httptest.NewRequest("POST", "/?typo=1", strings.NewReader(`{"name":"ok"}`))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
r = r.WithContext(WithRequestPolicy(context.Background(), strict))
|
||||
if err := b.BindWithPolicy(r, nil, &request{}, RequestPolicy{UnknownQuery: "ignore"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBodyPresencePolicies(t *testing.T) {
|
||||
b := MustNew[struct{}]()
|
||||
for _, mode := range []string{"none", "emptyObject", "required", "optional"} {
|
||||
for _, body := range []string{"", "{}", "null", `{"unexpected":1}`, "{} {}", " "} {
|
||||
r := httptest.NewRequest("POST", "/", byteReader{strings.NewReader(body)})
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
err := b.BindWithPolicy(r, nil, &struct{}{}, RequestPolicy{Body: mode, UTF8: "reject"})
|
||||
valid := (mode == "none" && body == "") || (mode != "none" && body == "{}") || (mode == "optional" && body == "")
|
||||
if (err == nil) != valid {
|
||||
t.Fatalf("mode=%s body=%q: %v", mode, body, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
type object struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
r := httptest.NewRequest("POST", "/", bytes.NewReader(nil))
|
||||
if err := MustNew[object]().BindWithPolicy(r, nil, &object{}, RequestPolicy{Body: "optional"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Chunked/unknown size is still bounded under strict UTF-8 checking.
|
||||
r = httptest.NewRequest("POST", "/", byteReader{strings.NewReader(`{"value":"1234567890"}`)})
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
if err := MustNew[object]().BindWithPolicy(r, nil, &object{}, RequestPolicy{UTF8: "reject"}, 8); err == nil {
|
||||
t.Fatal("chunked over-limit accepted")
|
||||
}
|
||||
}
|
||||
@@ -147,6 +147,10 @@ func responseSchema(n *node) *openapi.Schema {
|
||||
b := base(n)
|
||||
if b.scalar != nil {
|
||||
s := wireSchema(b.scalar.JSONOutput)
|
||||
s.Description = n.schema.Description
|
||||
if b.scalar.JSONInput == b.scalar.JSONOutput {
|
||||
s.Example = n.schema.Example
|
||||
}
|
||||
if n.typ.Kind() == reflect.Pointer {
|
||||
s = nullableSchema(s)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user