536 lines
16 KiB
Go
536 lines
16 KiB
Go
// 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)<<uint(t.Bits()-1)), float64((int64(1)<<uint(t.Bits()-1))-1)
|
|
n.schema.Minimum, n.schema.Maximum = &min, &max
|
|
}
|
|
case reflect.Float32, reflect.Float64:
|
|
n.schema.Type = "number"
|
|
if t.Kind() == reflect.Float32 {
|
|
n.schema.Format = "float"
|
|
} else {
|
|
n.schema.Format = "double"
|
|
}
|
|
case reflect.Slice, reflect.Map:
|
|
if t.Kind() == reflect.Map && t.Key().Kind() != reflect.String {
|
|
return nil, errors.New("contract: map keys must be strings")
|
|
}
|
|
if t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Uint8 && c.scalars[t.Elem()].Type == nil {
|
|
return nil, errors.New("contract: byte slices use base64 in Go; use string for binary data")
|
|
}
|
|
e, err := c.compile(t.Elem(), false, stack, depth+1, budget)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
n.elem = e
|
|
if t.Kind() == reflect.Slice {
|
|
n.schema.Type = "array"
|
|
n.schema.Items = e.schema
|
|
} else {
|
|
n.schema.Type = "object"
|
|
n.schema.AdditionalProperties = e.schema
|
|
}
|
|
case reflect.Struct:
|
|
n.schema.Type = "object"
|
|
allowed := false
|
|
n.schema.AdditionalPropertiesAllowed = &allowed
|
|
n.schema.Properties = map[string]*openapi.Schema{}
|
|
seen := map[string]bool{}
|
|
for i := 0; i < t.NumField(); i++ {
|
|
sf := t.Field(i)
|
|
if sf.PkgPath != "" || sf.Anonymous {
|
|
return nil, fmt.Errorf("contract: %s must be an exported named field", sf.Name)
|
|
}
|
|
f, err := c.compileField(sf, root, stack, depth+1, budget)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("contract: %s: %w", sf.Name, err)
|
|
}
|
|
f.index = i
|
|
key := f.source + ":" + strings.ToLower(f.name)
|
|
if seen[key] {
|
|
return nil, fmt.Errorf("contract: duplicate field %s", f.name)
|
|
}
|
|
seen[key] = true
|
|
n.fields = append(n.fields, f)
|
|
if f.source == "json" {
|
|
n.schema.Properties[f.name] = f.value.schema
|
|
if !f.optional && !f.hasDefault {
|
|
n.schema.Required = append(n.schema.Required, f.name)
|
|
}
|
|
}
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("contract: unsupported type %s", t)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
func (c compiler) compileField(sf reflect.StructField, root bool, stack map[reflect.Type]bool, depth int, budget *int) (field, error) {
|
|
f := field{}
|
|
// Reject typos and unsupported tags instead of silently losing validation.
|
|
tags, err := ParseTags(string(sf.Tag))
|
|
if err != nil {
|
|
return f, err
|
|
}
|
|
var sourceTag string
|
|
for key, value := range tags {
|
|
switch key {
|
|
case "json", "path", "form", "header", "file", "multipart", "multipartJSON":
|
|
if value == "-" {
|
|
return f, errors.New("ignored fields are not part of an API contract")
|
|
}
|
|
if f.source != "" {
|
|
return f, errors.New("exactly one source tag is required")
|
|
}
|
|
f.source = key
|
|
sourceTag = value
|
|
case "description", "example", "enumDescriptions":
|
|
// Documentation is validated after the field constraints.
|
|
case "binding":
|
|
f.binding = value
|
|
default:
|
|
return f, fmt.Errorf("unsupported tag %q", key)
|
|
}
|
|
}
|
|
if f.source == "" {
|
|
return f, errors.New("json, path, form or header tag required")
|
|
}
|
|
if !root && f.source != "json" {
|
|
return f, errors.New("nested fields must use json tags")
|
|
}
|
|
parts := strings.Split(sourceTag, ",")
|
|
f.name = parts[0]
|
|
if (f.source == "header" && !headerName.MatchString(f.name)) || (f.source != "header" && !wireName.MatchString(f.name)) {
|
|
return f, errors.New("wire names must be lowerCamelCase (headers use HTTP names)")
|
|
}
|
|
n, err := c.compile(sf.Type, false, stack, depth, budget)
|
|
if err != nil {
|
|
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 {
|
|
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)
|
|
}
|