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

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