feat: complete contract-driven business development and MySQL integration

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

View File

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

View File

@@ -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
View 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
}

View 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
View 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
View 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
View 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
View 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
View 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")
}
}

View File

@@ -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)
}