// Package contract compiles API field tags into a reusable request binder and // OpenAPI schemas. Generated services and the CLI use the same compiler. package contract import ( "encoding/json" "errors" "fmt" "reflect" "regexp" "strconv" "strings" "git.yuntuo.hk/k2safe/go-omni/openapi" "github.com/go-playground/validator/v10" ) const DefaultMaxBodyBytes int64 = 1 << 20 var wireName = regexp.MustCompile(`^[a-z][A-Za-z0-9]*$`) var headerName = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9-]*$`) var valuesValidator = validator.New() // Binder is immutable after compilation and safe for concurrent requests. type Binder struct { root *node typ reflect.Type schemaOnly bool } type node struct { scalar *Scalar typ reflect.Type schema *openapi.Schema fields []field elem *node } type field struct { index int maxBytes int64 maxFiles int name, source string value *node optional bool fallback any hasDefault bool binding string } func New[T any](scalars ...Scalar) (*Binder, error) { return ForType(reflect.TypeOf((*T)(nil)).Elem(), scalars...) } func MustNew[T any](scalars ...Scalar) *Binder { b, err := New[T](scalars...) if err != nil { panic(err) } return b } // ForType also lets generators check the exact runtime contract before writing files. // The root must be a struct; custom marshalers require explicit scalar registration. func ForType(t reflect.Type, scalars ...Scalar) (*Binder, error) { return build(t, false, scalars) } // Describe compiles schemas without loading or executing application Go types. // The returned Binder cannot bind requests; it is for generators only. func Describe(t reflect.Type, scalars ...Scalar) (*Binder, error) { return build(t, true, scalars) } func build(t reflect.Type, schemaOnly bool, scalars []Scalar) (*Binder, error) { if t == nil || t.Kind() != reflect.Struct { return nil, errors.New("contract: root must be a struct") } c := compiler{scalars: map[reflect.Type]Scalar{}} for _, scalar := range scalars { if err := scalar.validate(schemaOnly); err != nil { return nil, err } if _, exists := c.scalars[scalar.Type]; exists { return nil, fmt.Errorf("contract: duplicate scalar type %s", scalar.Type) } c.scalars[scalar.Type] = scalar } budget := 10000 n, err := c.compile(t, true, map[reflect.Type]bool{}, 0, &budget) if err != nil { return nil, err } return &Binder{root: n, typ: t, schemaOnly: schemaOnly}, nil } func (c compiler) compile(t reflect.Type, root bool, stack map[reflect.Type]bool, depth int, budget *int) (*node, error) { *budget-- if *budget < 0 { return nil, errors.New("contract: schema expansion exceeds 10000 nodes") } if depth > 32 { return nil, errors.New("contract: type nesting exceeds 32") } if stack[t] { return nil, errors.New("contract: recursive types are not supported") } stack[t] = true defer delete(stack, t) n := &node{typ: t, schema: &openapi.Schema{}} if scalar, ok := c.scalars[t]; ok { if root { return nil, errors.New("contract: root cannot be a scalar") } n.scalar = &scalar n.schema = scalar.inputSchema() return n, nil } // Wire behavior must be described completely by these tags. marshaler := reflect.TypeOf((*json.Marshaler)(nil)).Elem() unmarshaler := reflect.TypeOf((*json.Unmarshaler)(nil)).Elem() if t.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 { return nil, errors.New("contract: only any / interface{} is supported") } case reflect.Pointer: e, err := c.compile(t.Elem(), false, stack, depth+1, budget) if err != nil { return nil, err } n.elem = e n.schema = nullableSchema(e.schema) case reflect.String: n.schema.Type = "string" case reflect.Bool: n.schema.Type = "boolean" case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: n.schema.Type = "integer" if t.Bits() <= 32 { n.schema.Format = "int32" } else { n.schema.Format = "int64" } if t.Kind() >= reflect.Uint && t.Kind() <= reflect.Uint64 { v := float64(0) n.schema.Minimum = &v if t.Bits() < 64 { max := float64((uint64(1) << uint(t.Bits())) - 1) n.schema.Maximum = &max if t.Bits() == 32 { n.schema.Format = "int64" } } else { n.schema.Format = "" } } else if t.Bits() < 32 { min, max := -float64(int64(1)< 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 { opaque = base(base(n).elem).scalar } if opaque != nil { if isList && (f.source == "path" || f.source == "header") { return f, errors.New("path/header values must be scalars") } if !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 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 !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) { return f, errors.New("path values must be scalars") } if f.source == "header" && !isScalar(scalar) { return f, errors.New("header values must be scalars") } options := map[string]bool{} for _, option := range parts[1:] { key, value, _ := strings.Cut(option, "=") if options[key] { return f, fmt.Errorf("duplicate option %q", key) } options[key] = true switch key { case "optional", "omitempty": if option != key { return f, errors.New("optional takes no value") } f.optional = true case "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") } v, e := parseScalar(value, base(n).typ) if e != nil { return f, fmt.Errorf("invalid default: %w", e) } f.fallback = v f.hasDefault = true n.schema.Default = v case "options": if !isScalar(scalar) || value == "" { return f, errors.New("options requires scalar values") } for _, s := range strings.Split(value, "|") { v, e := parseScalar(s, base(n).typ) if e != nil { return f, e } n.schema.Enum = append(n.schema.Enum, v) } case "range": if !isNumber(scalar) { return f, errors.New("range requires numeric type") } if err = applyRange(n.schema, value); err != nil { return f, err } default: return f, fmt.Errorf("unsupported field option %q", option) } } if err = applyBinding(&f); err != nil { return f, err } if n.schema.Nullable && len(n.schema.Enum) > 0 { n.schema.Enum = append(n.schema.Enum, nil) } if f.source == "path" && (f.optional || f.hasDefault || sf.Type.Kind() == reflect.Pointer) { return f, errors.New("path fields must be required non-pointer values") } if f.hasDefault { v := reflect.New(sf.Type).Elem() if err = setValue(n, v, f.fallback); err != nil { return f, err } if err = checkField(f, v); err != nil { return f, fmt.Errorf("default violates validation: %w", err) } } if err := applyDocumentation(&f, tags); err != nil { return f, err } return f, nil } // ParseTags strictly parses Go struct tags, including duplicate/unknown key checks // performed by the compiler. It is also used to normalize generated Go JSON tags. func ParseTags(raw string) (map[string]string, error) { out := map[string]string{} for strings.TrimSpace(raw) != "" { raw = strings.TrimLeft(raw, " ") i := strings.Index(raw, ":") if i < 1 { return nil, errors.New("invalid struct tag") } key := raw[:i] raw = raw[i+1:] if strings.ContainsAny(key, " \t\r\n\"`") || len(raw) == 0 || raw[0] != '"' { return nil, errors.New("invalid struct tag") } end := 1 for end < len(raw) { if raw[end] == '\\' { end += 2 continue } if raw[end] == '"' { break } end++ } if end >= len(raw) { return nil, errors.New("unterminated struct tag") } v, err := strconv.Unquote(raw[:end+1]) if err != nil { return nil, err } if _, ok := out[key]; ok { return nil, fmt.Errorf("duplicate tag %q", key) } out[key] = v raw = raw[end+1:] if raw != "" && raw[0] != ' ' { return nil, errors.New("struct tags require spaces") } } return out, nil } func base(n *node) *node { for n.typ.Kind() == reflect.Pointer { n = n.elem } return n } func isNumber(k reflect.Kind) bool { return k >= reflect.Int && k <= reflect.Float64 && k != reflect.Uintptr } func isScalar(k reflect.Kind) bool { return k == reflect.String || k == reflect.Bool || isNumber(k) } func (b *Binder) BodySchema() *openapi.Schema { if len(b.root.schema.Properties) == 0 { return nil } return copySchema(b.root.schema) } // ResponseSchema rejects request-only fields to keep response docs honest. func (b *Binder) ResponseSchema() (*openapi.Schema, error) { for _, f := range b.root.fields { if f.source != "json" { return nil, errors.New("contract: response fields must use json tags") } } return responseSchema(b.root), nil } func (b *Binder) Parameters() []openapi.Parameter { var out []openapi.Parameter for _, f := range b.root.fields { if f.source == "json" || multipartSource(f.source) { continue } in := f.source if in == "form" { in = "query" } schema := copySchema(f.value.schema) if base(f.value).scalar != nil { schema = &openapi.Schema{Type: "string"} } else if base(f.value).typ.Kind() == reflect.Slice && base(base(f.value).elem).scalar != nil { schema.Items = &openapi.Schema{Type: "string"} } out = append(out, openapi.Parameter{Name: f.name, In: in, Required: !f.optional && !f.hasDefault, Schema: schema, Description: f.value.schema.Description}) } return out } func copySchema(s *openapi.Schema) *openapi.Schema { data, _ := json.Marshal(s) var out 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) }